* 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 | 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's | 758 | // 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-selected | 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 | 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'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-in | 1538 | 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 app | 1555 | {dcrFallbackMessage |
| 1556 | ? "Manually register an app" | ||
| 1557 | : "Register app"} | ||
| 1533 | </Button> | 1558 | </Button> |
| 1534 | {oauthOtherApps.length > 0 && !showOtherApps ? ( | 1559 | {oauthOtherApps.length > 0 && !showOtherApps ? ( |
| 1535 | <Button | 1560 | <Button |
| @@ -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 | {canRegisterDynamic | 297 | {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 the | 324 | {/* 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 manually | 389 | or enter a client ID manually |