import React, { useState, useEffect, useRef, useCallback } from "react"; import { Box, Text, useInput, type Key } from "ink"; import { ScrollView, type ScrollViewRef } from "ink-scroll-view"; import { SelectableItem } from "./SelectableItem.js"; import type { MCPServerConfig, InspectorClient, ConnectionStatus, } from "@inspector/core/mcp/index.js"; import type { OAuthConnectionState } from "@inspector/core/auth/types.js"; import type { AuthChallenge } from "@inspector/core/auth/challenge.js"; import { formatAuthProtocol, formatClientRegistrationKind, formatIdpSession, formatScopes, } from "../utils/oauthDisplay.js"; import { stepUpAuthorizeActionLabel, stepUpConfirmMessage, stepUpFollowUpMessage, stepUpModalTitle, } from "../utils/tuiOAuth.js"; interface AuthTabProps { serverName: string | null; serverConfig: MCPServerConfig | null; inspectorClient: InspectorClient | null; oauthStatus: "idle" | "authenticating" | "error"; oauthMessage: string | null; /** * How to colour {@link oauthMessage} on the `idle` status. Defaults to * `"info"` (cyan). `"warning"` exists for the one message that reports a * *partial* success: the OAuth state really was cleared, so this is not an * error, but the grant may still be live at the authorization server and * cyan would understate that (#2144). */ oauthMessageTone?: "info" | "warning"; oauthRevision: number; pendingStepUp?: { challenge: AuthChallenge; authorizationScopes?: string[]; enterpriseManaged?: boolean; } | null; onAuthorizeStepUp?: () => void; onCancelStepUp?: () => void; width: number; height: number; focused?: boolean; /** * Clears (and, unless opted out, revokes) this server's OAuth state. * * Returns a promise so the confirmation below can wait for it. The RFC 7009 * revocation leg is a network request with a five-second bound (#2144), so * this is no longer instantaneous, and announcing "cleared" on the keypress * would say it while the work was still in flight. */ onClearOAuth: () => void | Promise; connectionStatus: ConnectionStatus; } function OAuthDetailRow({ label, value }: { label: string; value: string }) { return ( {label}: {value} ); } export function AuthTab({ serverName, inspectorClient, oauthStatus, oauthMessage, oauthMessageTone = "info", oauthRevision, pendingStepUp, onAuthorizeStepUp, onCancelStepUp, width, height, focused = false, onClearOAuth, connectionStatus, }: AuthTabProps) { const isLiveConnection = connectionStatus === "connected" || connectionStatus === "connecting"; const scrollViewRef = useRef(null); const [oauthState, setOauthState] = useState< OAuthConnectionState | undefined >(undefined); const [clearState, setClearState] = useState< "idle" | "clearing" | "cleared" | "failed" >("idle"); const [clearFailure, setClearFailure] = useState(null); /** * The server a clear was started for, and a sequence number. The clear is a * bounded network request now, so it can settle after the user has moved on * — at which point server A's confirmation must not appear under server B. */ const clearAttemptRef = useRef(0); /** * In-flight lock. A ref, not the `clearState` above: two `s` keypresses * delivered in the same input turn both read the state React last rendered, * so a state-based guard lets both start and race each other over the same * store entry. A ref is set before the call and seen by the second read. */ const clearInFlightRef = useRef(false); /** * Set synchronously when a clear starts, and consumed by the reset effect * below. The clear itself bumps `oauthRevision` on its way out, so without * this the reset would race the confirmation it is meant to outlive — and * which of the two lands first is not something the ordering guarantees. */ const ownClearRef = useRef(false); const serverNameRef = useRef(serverName); useEffect(() => { serverNameRef.current = serverName; }, [serverName]); // A new selection retires any in-flight clear and drops the previous one's // banner: neither belongs to the server now on screen. useEffect(() => { clearAttemptRef.current++; clearInFlightRef.current = false; // The retired clear returns before its `oauthRevision` bump, so the marker // has no bump to skip. Left set, it would swallow the first unrelated // revision on the server just selected and strand that server's banner. ownClearRef.current = false; setClearState("idle"); setClearFailure(null); setLastClearDisconnected(false); }, [serverName]); const [lastClearDisconnected, setLastClearDisconnected] = useState(false); const [stepUpChoiceIndex, setStepUpChoiceIndex] = useState(0); const refreshOAuthState = useCallback(async () => { if (!inspectorClient) { setOauthState(undefined); return; } const state = await inspectorClient.getOAuthState(); setOauthState(state); }, [inspectorClient]); useEffect(() => { void refreshOAuthState(); }, [refreshOAuthState, oauthRevision, connectionStatus]); useEffect(() => { // Skip exactly the revision bump our own clear caused; reset on the next. if (ownClearRef.current) { ownClearRef.current = false; return; } setClearState("idle"); setClearFailure(null); setLastClearDisconnected(false); }, [oauthRevision]); useEffect(() => { setStepUpChoiceIndex(0); }, [pendingStepUp]); useEffect(() => { if (!inspectorClient) return; const update = () => { void refreshOAuthState(); }; inspectorClient.addEventListener("oauthComplete", update); return () => { inspectorClient.removeEventListener("oauthComplete", update); }; }, [inspectorClient, refreshOAuthState]); useInput( (input: string, key: Key) => { if (!focused) return; if (pendingStepUp) { if (key.upArrow) { setStepUpChoiceIndex((i) => Math.max(0, i - 1)); return; } if (key.downArrow) { setStepUpChoiceIndex((i) => Math.min(1, i + 1)); return; } if (key.return) { if (stepUpChoiceIndex === 0) { onAuthorizeStepUp?.(); } else { onCancelStepUp?.(); } return; } if (input.toLowerCase() === "a") { onAuthorizeStepUp?.(); return; } if (input.toLowerCase() === "c") { onCancelStepUp?.(); return; } return; } if (key.upArrow && scrollViewRef.current) { scrollViewRef.current.scrollBy(-1); } else if (key.downArrow && scrollViewRef.current) { scrollViewRef.current.scrollBy(1); } else if (key.pageUp && scrollViewRef.current) { const h = scrollViewRef.current.getViewportHeight() || 1; scrollViewRef.current.scrollBy(-h); } else if (key.pageDown && scrollViewRef.current) { const h = scrollViewRef.current.getViewportHeight() || 1; scrollViewRef.current.scrollBy(h); } else if (input.toLowerCase() === "s") { // Ignore repeats while one is in flight: the second would race the // first over the same store entry, and the user cannot see that the // first is still running except by the pending line below. if (clearInFlightRef.current) return; clearInFlightRef.current = true; const attempt = ++clearAttemptRef.current; const attemptServer = serverName; setLastClearDisconnected(isLiveConnection); setClearFailure(null); ownClearRef.current = true; setClearState("clearing"); // A completion is only ours if nothing has superseded it and the // selection has not moved on. const current = () => clearAttemptRef.current === attempt && serverNameRef.current === attemptServer; void Promise.resolve(onClearOAuth()).then( () => { // `current()` FIRST, before touching either shared ref. A stale // completion — server A settling after the user moved to B and // started a clear there — would otherwise drop B's lock and let a // second B clear run concurrently. A stale clear owns nothing. if (!current()) return; clearInFlightRef.current = false; setClearState("cleared"); }, (err: unknown) => { if (!current()) return; clearInFlightRef.current = false; // A rejection is NOT a revocation failure — those come back as // outcomes and are reported through the message line. This is the // local clear or the disconnect itself failing, so announcing // "OAuth state cleared" here would be a plain lie. // // It also means `handleClearOAuth` never reached its // `oauthRevision` bump, so the marker below has no bump to skip. // Left set, it would swallow the next *unrelated* revision change // and strand this banner after the OAuth state moved on. ownClearRef.current = false; setClearFailure(err instanceof Error ? err.message : String(err)); setClearState("failed"); }, ); } }, { isActive: focused }, ); if (!serverName) { return ( Select a server to view authentication. ); } const scopes = oauthState ? formatScopes(oauthState) : undefined; const accessToken = oauthState?.tokens?.access_token; return ( OAuth {oauthStatus === "authenticating" && ( Authenticating… )} {oauthStatus === "error" && oauthMessage && ( {oauthMessage} )} {oauthStatus === "idle" && oauthMessage && ( {oauthMessage} )} {pendingStepUp ? ( {stepUpModalTitle({ enterpriseManaged: pendingStepUp.enterpriseManaged, })} {stepUpConfirmMessage(pendingStepUp.challenge, { enterpriseManaged: pendingStepUp.enterpriseManaged, })}{" "} {stepUpFollowUpMessage({ enterpriseManaged: pendingStepUp.enterpriseManaged, })} {(() => { const scopes = pendingStepUp.authorizationScopes ?? pendingStepUp.challenge.requiredScopes; if (!scopes?.length) return null; return ( {pendingStepUp.enterpriseManaged ? "Permissions requested:" : "Scopes requested:"} {scopes.map((scope) => ( {" "} • {scope} ))} ); })()} {(() => { const label = stepUpAuthorizeActionLabel({ enterpriseManaged: pendingStepUp.enterpriseManaged, }); return ( <> {label[0]} {label.slice(1)} ); })()} Cancel ) : null} {oauthState ? ( OAuth Details {oauthState.client?.clientId && ( )} {oauthState.client?.registrationKind && ( )} {oauthState.protocol === "ema" && oauthState.ema?.idpSession && ( )} {oauthState.authorizationServerMetadata ?.authorization_endpoint && ( )} {scopes && } {accessToken && ( )} ) : ( oauthStatus !== "authenticating" && ( No OAuth information yet. Connect (C) to authorize when this server requires it. ) )} Clear OAuth State {isLiveConnection && " and disconnect"} {clearState === "clearing" && ( Clearing OAuth state… )} {clearState === "cleared" && ( {lastClearDisconnected ? "OAuth state cleared. Disconnected." : "OAuth state cleared."} )} {clearState === "failed" && ( Could not clear OAuth state {clearFailure ? `: ${clearFailure}` : "."} )} {focused && ( {pendingStepUp ? "↑/↓ select, Enter confirm, A authorize, C cancel" : `S ${isLiveConnection ? "clear+disconnect" : "clear"}, ↑/↓ scroll`} )} ); }