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