refactor: one shared reporter for the terminal refusal (review round 4)
In round 2 I claimed a single wrapper "stops a future path omitting the banner clear". That was only true inside useOAuthRecovery. useConnectionLifecycle hand-wrote the notice-plus-scoped-clear pair three times, so the exact failure mode I said was impossible was one careless edit away — in the PR that claimed it. reportTerminalInsecureTokenEndpoint now lives in lib/ and both hooks route through it; zero hand-written pairs remain. It is generic over the banner shape rather than importing ReAuthBannerState, since useOAuthRecovery already imports this module and naming its type would close a cycle. All it needs is a serverId to compare. The helper's header also still said "the three OAuth failure paths". There are seven. Rather than update the count I removed the enumeration and recorded why: a list of callers is a comment that rots on the next round, which is what just happened to it. Also fixes "a /etc/hosts" -> "an /etc/hosts". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018MvNbmAtQuWhDvzi4yXDju Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
8ca00fa2d6
commit
c2d566ad8d
3 changed files with 91 additions and 44 deletions
|
|
@ -30,7 +30,7 @@ import {
|
|||
getActiveEnterpriseManagedAuthIdp,
|
||||
} from "@inspector/core/client/types.js";
|
||||
import { isEmaClientNotConfiguredError } from "@inspector/core/auth/ema/clientConfigError.js";
|
||||
import { showInsecureTokenEndpointNotice } from "../lib/insecureTokenEndpointNotice";
|
||||
import { reportTerminalInsecureTokenEndpoint } from "../lib/insecureTokenEndpointNotice";
|
||||
import { findInsecureTokenEndpoint } from "@inspector/core/auth/insecureTokenEndpoint.js";
|
||||
import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js";
|
||||
import type { RemoteInspectorClientStorage } from "@inspector/core/mcp/remote/index.js";
|
||||
|
|
@ -654,14 +654,12 @@ export function useConnectionLifecycle({
|
|||
// arm below already disconnects for the same reason.
|
||||
if (findInsecureTokenEndpoint(err)) {
|
||||
await client.disconnect().catch(() => {});
|
||||
showInsecureTokenEndpointNotice(err, target.name);
|
||||
// Clear a banner left by an earlier failure: its Re-authenticate
|
||||
// button is just as dead as the one this arm declines to offer, and
|
||||
// the user cannot tell which failure it belongs to. Scoped to this
|
||||
// server, so an async continuation cannot erase another's.
|
||||
setReAuthBanner((prev) =>
|
||||
prev && prev.serverId === id ? null : prev,
|
||||
);
|
||||
reportTerminalInsecureTokenEndpoint({
|
||||
err,
|
||||
serverId: id,
|
||||
serverName: target.name,
|
||||
setReAuthBanner,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -706,12 +704,14 @@ export function useConnectionLifecycle({
|
|||
// the user the authorization *worked*. Placed after the teardown
|
||||
// above, which this arm needs for the same reason the generic one
|
||||
// does, and before the flag it must not set.
|
||||
if (showInsecureTokenEndpointNotice(recoveryErr, target.name)) {
|
||||
// Only this server's banner — an async continuation must not
|
||||
// erase one raised for a server the user has since switched to.
|
||||
setReAuthBanner((prev) =>
|
||||
prev && prev.serverId === id ? null : prev,
|
||||
);
|
||||
if (
|
||||
reportTerminalInsecureTokenEndpoint({
|
||||
err: recoveryErr,
|
||||
serverId: id,
|
||||
serverName: target.name,
|
||||
setReAuthBanner,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setFailedServerId(id);
|
||||
|
|
@ -767,10 +767,14 @@ export function useConnectionLifecycle({
|
|||
}
|
||||
// See the note on the handshake arm above (#2280). The
|
||||
// disconnect already happened at the top of this catch.
|
||||
if (showInsecureTokenEndpointNotice(authErr, target.name)) {
|
||||
setReAuthBanner((prev) =>
|
||||
prev && prev.serverId === id ? null : prev,
|
||||
);
|
||||
if (
|
||||
reportTerminalInsecureTokenEndpoint({
|
||||
err: authErr,
|
||||
serverId: id,
|
||||
serverName: target.name,
|
||||
setReAuthBanner,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// The connect attempt failed, same as any other handshake error —
|
||||
|
|
@ -1021,7 +1025,14 @@ export function useConnectionLifecycle({
|
|||
// worst place to lose the guidance, since they have just been told
|
||||
// retrying is the fix. No banner clear is needed: this callback
|
||||
// already cleared it before starting.
|
||||
if (showInsecureTokenEndpointNotice(err, server?.name)) {
|
||||
if (
|
||||
reportTerminalInsecureTokenEndpoint({
|
||||
err,
|
||||
serverId,
|
||||
serverName: server?.name,
|
||||
setReAuthBanner,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ import {
|
|||
emaStepUpSuccessMessage,
|
||||
} from "@inspector/core/auth/oauthUx.js";
|
||||
import { isEmaClientNotConfiguredError } from "@inspector/core/auth/ema/clientConfigError.js";
|
||||
import { showInsecureTokenEndpointNotice } from "../lib/insecureTokenEndpointNotice";
|
||||
import { reportTerminalInsecureTokenEndpoint as reportTerminalInsecureTokenEndpointNotice } from "../lib/insecureTokenEndpointNotice";
|
||||
import type { OAuthDetails } from "../components/groups/ConnectionInfoContent/ConnectionInfoContent";
|
||||
import { oauthDetailsFromConnectionState } from "../components/groups/ConnectionInfoContent/oauthDetailsFromConnectionState";
|
||||
import { getWebRemoteOAuthStorage } from "../lib/remoteOAuthStorage";
|
||||
|
|
@ -408,21 +408,13 @@ export function useOAuthRecovery({
|
|||
err: unknown,
|
||||
serverId: string | undefined,
|
||||
serverName?: string,
|
||||
): boolean => {
|
||||
if (!showInsecureTokenEndpointNotice(err, serverName)) {
|
||||
return false;
|
||||
}
|
||||
// Clear only *this* server's banner. The command and deferred-resume
|
||||
// paths are asynchronous, so server A can reject long after the user
|
||||
// switched away and server B raised a banner of its own; an unconditional
|
||||
// clear would then erase B's, which is still valid and still actionable.
|
||||
// Functional so it sees the queued state rather than the render-time
|
||||
// value, matching how `setPendingReauth` guards its own late restore.
|
||||
setReAuthBanner((prev) =>
|
||||
prev && prev.serverId === serverId ? null : prev,
|
||||
);
|
||||
return true;
|
||||
},
|
||||
): boolean =>
|
||||
reportTerminalInsecureTokenEndpointNotice({
|
||||
err,
|
||||
serverId,
|
||||
serverName,
|
||||
setReAuthBanner,
|
||||
}),
|
||||
[setReAuthBanner],
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,17 +1,18 @@
|
|||
/**
|
||||
* Surfaces the SDK's terminal token-endpoint refusal to post credentials to a non-TLS token
|
||||
* endpoint as the terminal configuration error it is (#2280).
|
||||
* Surfaces the SDK's refusal to post credentials to a non-TLS token endpoint as
|
||||
* the terminal configuration error it is (#2280).
|
||||
*
|
||||
* Lives in `lib/` rather than `utils/` because showing a notification is a side
|
||||
* effect; the copy it renders is pure and lives in `@inspector/core/auth`.
|
||||
*
|
||||
* Shaped as a claim-or-decline predicate rather than a plain `show(...)` so the
|
||||
* three OAuth failure paths that need it — the connect handshake, the post-
|
||||
* redirect callback, and the re-auth banner funnel — can each spend one line on
|
||||
* it and keep their existing fall-through intact:
|
||||
* Shaped as a claim-or-decline predicate rather than a plain `show(...)` so
|
||||
* every OAuth failure path that needs it can spend one line and keep its own
|
||||
* fall-through intact. Deliberately not enumerating the callers here: they have
|
||||
* gone from three to seven over this PR's review, and a list is a comment that
|
||||
* rots on the next one.
|
||||
*
|
||||
* ```ts
|
||||
* if (showInsecureTokenEndpointNotice(err, server.name)) return;
|
||||
* if (reportTerminalInsecureTokenEndpoint({ err, serverId: id, serverName, setReAuthBanner })) return;
|
||||
* ```
|
||||
*
|
||||
* `autoClose: false` matches the other non-recoverable OAuth notices (issuer
|
||||
|
|
@ -22,6 +23,7 @@
|
|||
* reading. Don't describe this as non-dismissible.
|
||||
*/
|
||||
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { findInsecureTokenEndpoint } from "@inspector/core/auth/insecureTokenEndpoint.js";
|
||||
import {
|
||||
|
|
@ -35,6 +37,48 @@ import {
|
|||
* @returns `true` when it was handled (the caller should stop), `false` when
|
||||
* `err` is some other failure and the caller's normal handling applies.
|
||||
*/
|
||||
/**
|
||||
* Report the refusal **and** clear the re-auth banner for that server.
|
||||
*
|
||||
* This is the form every caller should use. The notice and the scoped banner
|
||||
* clear are one invariant, not two steps: a banner left behind carries a
|
||||
* Re-authenticate button as dead as the one this declines to offer, and the
|
||||
* user cannot tell which failure it belongs to. Keeping them together in one
|
||||
* place is what stops a future path doing half of it — an earlier revision made
|
||||
* that claim while hand-writing the pair at three call sites in a second hook,
|
||||
* which is exactly how it goes wrong.
|
||||
*
|
||||
* The clear is scoped to `serverId` and applied as a functional update: these
|
||||
* paths are asynchronous, so a late continuation for server A must not erase a
|
||||
* banner server B raised in the meantime.
|
||||
*
|
||||
* Generic over the banner shape rather than importing `ReAuthBannerState`:
|
||||
* `useOAuthRecovery` already imports this module, so naming its type here would
|
||||
* close a cycle. All this needs is a `serverId` to compare.
|
||||
*
|
||||
* @returns `true` when it was handled (the caller should stop), `false` when
|
||||
* `err` is some other failure and the caller's normal handling applies.
|
||||
*/
|
||||
export function reportTerminalInsecureTokenEndpoint<
|
||||
TBanner extends { serverId: string },
|
||||
>({
|
||||
err,
|
||||
serverId,
|
||||
serverName,
|
||||
setReAuthBanner,
|
||||
}: {
|
||||
err: unknown;
|
||||
serverId: string | undefined;
|
||||
serverName?: string;
|
||||
setReAuthBanner: Dispatch<SetStateAction<TBanner | null>>;
|
||||
}): boolean {
|
||||
if (!showInsecureTokenEndpointNotice(err, serverName)) {
|
||||
return false;
|
||||
}
|
||||
setReAuthBanner((prev) => (prev && prev.serverId === serverId ? null : prev));
|
||||
return true;
|
||||
}
|
||||
|
||||
export function showInsecureTokenEndpointNotice(
|
||||
err: unknown,
|
||||
serverName?: string,
|
||||
|
|
|
|||
Loading…
Reference in a new issue