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
751 const [dcrBusy, setDcrBusy] = useState(false);751 const [dcrBusy, setDcrBusy] = useState(false);
752 const [dcrFailed, setDcrFailed] = useState(false);752 const [dcrFailed, setDcrFailed] = useState(false);
753 const [oauthFallbackProbe, setOAuthFallbackProbe] = useState<DcrProbeResult | null>(null);753 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);
754 // FIX 3 escape hatch: when no registered app matched the integration's758 // FIX 3 escape hatch: when no registered app matched the integration's
755 // endpoints, the unmatched apps are collapsed behind an opt-in expander.759 // endpoints, the unmatched apps are collapsed behind an opt-in expander.
756 const [showOtherApps, setShowOtherApps] = useState(false);760 const [showOtherApps, setShowOtherApps] = useState(false);
@@ -817,6 +821,7 @@ function AddAccountModalView(props: AddAccountModalProps) { · 60 unmodified lines
817 setPickedApp(null);821 setPickedApp(null);
818 setOAuthFallbackProbe(null);822 setOAuthFallbackProbe(null);
819 setDcrFailed(false);823 setDcrFailed(false);
824 setDcrFallbackMessage(null);
820 }, [initialState, allMethods, defaultOwner, ownerOptions]);825 }, [initialState, allMethods, defaultOwner, ownerOptions]);
821 826
822 useEffect(() => {827 useEffect(() => {
@@ -1004,6 +1009,7 @@ function AddAccountModalView(props: AddAccountModalProps) { · 181 unmodified lines
1004 setOnePasswordItemId("");1009 setOnePasswordItemId("");
1005 setDcrFailed(false);1010 setDcrFailed(false);
1006 setOAuthFallbackProbe(null);1011 setOAuthFallbackProbe(null);
1012 setDcrFallbackMessage(null);
1007 };1013 };
1008 1014
1009 // A just-created custom method joins the in-session list and is auto-selected1015 // 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
1267 if (outcome.kind === "fallback") {1273 if (outcome.kind === "fallback") {
1268 setOAuthFallbackProbe("probe" in outcome ? outcome.probe : null);1274 setOAuthFallbackProbe("probe" in outcome ? outcome.probe : null);
1269 setDcrFailed(true);1275 setDcrFailed(true);
1270 toast.error(1276 // Surface the server's actionable rejection reason on the recovery view as
1271 ("message" in outcome ? outcome.message : undefined) ??1277 // an inline error card. Generic fallbacks (no message) fall through to the
1272 "Automatic setup unavailable. Register an app instead.",1278 // "register an app" empty state, which already guides the user.
1273 );1279 setDcrFallbackMessage("message" in outcome ? (outcome.message ?? null) : null);
1274 }1280 }
1275 };1281 };
1276 1282
@@ -1342,6 +1348,7 @@ function AddAccountModalView(props: AddAccountModalProps) { · 65 unmodified lines
1342 existingSlugs={[...oauthApps, ...oauthOtherApps].map((app: OAuthClientOption) =>1348 existingSlugs={[...oauthApps, ...oauthOtherApps].map((app: OAuthClientOption) =>
1343 String(app.slug),1349 String(app.slug),
1344 )}1350 )}
1351 autoRegisterRejectedReason={dcrFallbackMessage}
1345 prefill={{1352 prefill={{
1346 authorizationUrl:1353 authorizationUrl:
1347 oauthHandoffPrefill?.authorizationUrl ??1354 oauthHandoffPrefill?.authorizationUrl ??
@@ -1511,6 +1518,22 @@ function AddAccountModalView(props: AddAccountModalProps) { · 163 unmodified lines
1511 <p className="text-xs text-muted-foreground">Loading OAuth apps…</p>1518 <p className="text-xs text-muted-foreground">Loading OAuth apps…</p>
1512 ) : (1519 ) : (
1513 <div className="space-y-3">1520 <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}
1514 {/* No registered app matched the integration's endpoint:1537 {/* No registered app matched the integration's endpoint:
1515 empty state + a prominent register CTA, and an opt-in1538 empty state + a prominent register CTA, and an opt-in
1516 collapsed "use a different registered app" escape hatch. */}1539 collapsed "use a different registered app" escape hatch. */}
@@ -1529,7 +1552,9 @@ function AddAccountModalView(props: AddAccountModalProps) { · 12 unmodified lines
1529 size="sm"1552 size="sm"
1530 onClick={() => setRegisteringOAuthClient(true)}1553 onClick={() => setRegisteringOAuthClient(true)}
1531 >1554 >
1532 Register app1555 {dcrFallbackMessage
1556 ? "Manually register an app"
1557 : "Register app"}
1533 </Button>1558 </Button>
1534 {oauthOtherApps.length > 0 && !showOtherApps ? (1559 {oauthOtherApps.length > 0 && !showOtherApps ? (
1535 <Button1560 <Button
packages/react/src/components/oauth-client-form.tsx+21−3
@@ -88,6 +88,11 @@ export function OAuthClientForm(props: { · 87 unmodified lines
88 readonly onCreated: (result: { readonly owner: Owner; readonly slug: OAuthClientSlug }) => void;88 readonly onCreated: (result: { readonly owner: Owner; readonly slug: OAuthClientSlug }) => void;
89 readonly onCancel?: () => void;89 readonly onCancel?: () => void;
90 readonly surface?: "card" | "plain";90 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;
91}) {96}) {
92 const {97 const {
93 integrationName,98 integrationName,
@@ -98,6 +103,7 @@ export function OAuthClientForm(props: { · 4 unmodified lines
98 onCreated,103 onCreated,
99 onCancel,104 onCancel,
100 surface = "card",105 surface = "card",
106 autoRegisterRejectedReason = null,
101 } = props;107 } = props;
102 // Non-org hosts (local/desktop) have one local workspace. Offer only Local,108 // Non-org hosts (local/desktop) have one local workspace. Offer only Local,
103 // so the owner dropdown (which hides on a single option) disappears.109 // so the owner dropdown (which hides on a single option) disappears.
@@ -178,6 +184,11 @@ export function OAuthClientForm(props: { · 74 unmodified lines
178 tokenUrl.trim().length > 0 &&184 tokenUrl.trim().length > 0 &&
179 grant === "authorization_code";185 grant === "authorization_code";
180 186
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
181 const handleDiscover = async () => {192 const handleDiscover = async () => {
182 const url = issuerUrl.trim();193 const url = issuerUrl.trim();
183 if (url.length === 0) {194 if (url.length === 0) {
@@ -283,12 +294,19 @@ export function OAuthClientForm(props: { · 99 unmodified lines
283 <div className="space-y-1">294 <div className="space-y-1">
284 <p className="text-sm font-medium">Register an OAuth app</p>295 <p className="text-sm font-medium">Register an OAuth app</p>
285 <p className="text-xs text-muted-foreground">296 <p className="text-xs text-muted-foreground">
286 {canRegisterDynamic297 {showAutoRegister
287 ? "Register automatically below, or enter a client id/secret manually."298 ? "Register automatically below, or enter a client id/secret manually."
288 : "Paste a client id/secret. We only ask for endpoints when they aren't already known."}299 : "Paste a client id/secret. We only ask for endpoints when they aren't already known."}
289 </p>300 </p>
290 </div>301 </div>
291 302
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
292 {/* app name */}310 {/* app name */}
293 <div className="space-y-1.5">311 <div className="space-y-1.5">
294 <Label htmlFor="oauth-app-name" className="text-xs text-muted-foreground">312 <Label htmlFor="oauth-app-name" className="text-xs text-muted-foreground">
@@ -305,7 +323,7 @@ export function OAuthClientForm(props: { · 10 unmodified lines
305 323
306 {/* register automatically (RFC 7591 DCR) — the primary path when the324 {/* register automatically (RFC 7591 DCR) — the primary path when the
307 server advertises a registration endpoint: no client id/secret needed */}325 server advertises a registration endpoint: no client id/secret needed */}
308 {canRegisterDynamic ? (326 {showAutoRegister ? (
309 <div className="space-y-2 rounded-lg border border-ring/40 bg-accent/30 p-3">327 <div className="space-y-2 rounded-lg border border-ring/40 bg-accent/30 p-3">
310 <p className="text-sm font-medium">No client ID needed</p>328 <p className="text-sm font-medium">No client ID needed</p>
311 <p className="text-xs text-muted-foreground">329 <p className="text-xs text-muted-foreground">
@@ -365,7 +383,7 @@ export function OAuthClientForm(props: { · 53 unmodified lines
365 </div>383 </div>
366 384
367 {/* divider before the manual (secondary) path when DCR is available */}385 {/* divider before the manual (secondary) path when DCR is available */}
368 {canRegisterDynamic ? (386 {showAutoRegister ? (
369 <div className="flex items-center gap-2 text-xs text-muted-foreground">387 <div className="flex items-center gap-2 text-xs text-muted-foreground">
370 <span className="h-px flex-1 bg-border/60" />388 <span className="h-px flex-1 bg-border/60" />
371 or enter a client ID manually389 or enter a client ID manually