Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
499 changes: 106 additions & 393 deletions mcpjam-inspector/client/src/components/OAuthFlowTab.tsx

Large diffs are not rendered by default.

467 changes: 467 additions & 0 deletions mcpjam-inspector/client/src/components/__tests__/OAuthFlowTab.test.tsx

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -4,61 +4,16 @@ interface OAuthAuthorizationModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
authorizationUrl: string;
onAuthorizationComplete?: () => void;
}

export const OAuthAuthorizationModal = ({
open,
onOpenChange,
authorizationUrl,
onAuthorizationComplete,
}: OAuthAuthorizationModalProps) => {
const popupRef = useRef<Window | null>(null);
const hasOpenedRef = useRef(false);

// Listen for OAuth callback messages from popup
useEffect(() => {
// Method 1: Listen via window.postMessage (standard approach)
const handleMessage = (event: MessageEvent) => {
// Verify origin matches our app
if (event.origin !== window.location.origin) {
return;
}

// Check if this is an OAuth callback message
if (event.data?.type === "OAUTH_CALLBACK" && event.data?.code) {
// Notify parent component
onAuthorizationComplete?.();
// Close our "modal" state
onOpenChange(false);
hasOpenedRef.current = false;
}
};

// Method 2: Listen via BroadcastChannel (fallback for COOP-protected OAuth servers)
let channel: BroadcastChannel | null = null;
try {
channel = new BroadcastChannel("oauth_callback_channel");
channel.onmessage = (event) => {
if (event.data?.type === "OAUTH_CALLBACK" && event.data?.code) {
// Notify parent component
onAuthorizationComplete?.();
// Close our "modal" state
onOpenChange(false);
hasOpenedRef.current = false;
}
};
} catch (error) {
console.warn("[OAuth Popup] BroadcastChannel not supported:", error);
}

window.addEventListener("message", handleMessage);
return () => {
window.removeEventListener("message", handleMessage);
channel?.close();
};
}, [onOpenChange, onAuthorizationComplete]);

// Open popup when modal opens
useEffect(() => {
if (open && !hasOpenedRef.current) {
Expand Down
152 changes: 152 additions & 0 deletions mcpjam-inspector/client/src/components/oauth/OAuthFlowExperience.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable";
import { OAuthSequenceDiagram } from "@/components/oauth/OAuthSequenceDiagram";
import { OAuthAuthorizationModal } from "@/components/oauth/OAuthAuthorizationModal";
import { OAuthFlowLogger } from "@/components/oauth/OAuthFlowLogger";
import { RefreshTokensConfirmModal } from "@/components/oauth/RefreshTokensConfirmModal";
import type {
OAuthFlowState,
OAuthFlowStep,
OAuthProtocolVersion,
} from "@/lib/oauth/state-machines/types";
import type { OAuthRegistrationStrategy } from "@/lib/oauth/profile";
import {
resolveOAuthFlowExperienceCapabilities,
type OAuthFlowExperienceConfig,
type OAuthFlowExperienceSummary,
} from "./oauthFlowShared";

interface OAuthFlowExperienceProps {
flowState: OAuthFlowState;
focusedStep: OAuthFlowStep | null;
onFocusStep: (step: OAuthFlowStep | null) => void;
hasProfile: boolean;
protocolVersion: OAuthProtocolVersion;
registrationStrategy: OAuthRegistrationStrategy;
summary: OAuthFlowExperienceSummary;
config?: OAuthFlowExperienceConfig;
onClearLogs: () => void;
onClearHttpHistory: () => void;
onConfigureTarget?: () => void | Promise<void>;
onReset?: () => void | Promise<void>;
onContinue?: () => void | Promise<void>;
continueLabel: string;
continueDisabled: boolean;
onApplyTokens?: () => void | Promise<void>;
onRefreshTokens?: () => void | Promise<void>;
isApplyingTokens?: boolean;
authModal: {
open: boolean;
onOpenChange: (open: boolean) => void;
authorizationUrl?: string;
};
refreshModal?: {
open: boolean;
onOpenChange: (open: boolean) => void;
serverName: string;
onConfirm: () => void | Promise<void>;
isLoading?: boolean;
};
}

export function OAuthFlowExperience({
flowState,
focusedStep,
onFocusStep,
hasProfile,
protocolVersion,
registrationStrategy,
summary,
config,
onClearLogs,
onClearHttpHistory,
onConfigureTarget,
onReset,
onContinue,
continueLabel,
continueDisabled,
onApplyTokens,
onRefreshTokens,
isApplyingTokens,
authModal,
refreshModal,
}: OAuthFlowExperienceProps) {
const capabilities = resolveOAuthFlowExperienceCapabilities(config);
const canConfigureTarget =
capabilities.canConfigureTarget && Boolean(onConfigureTarget);
const canEditTarget =
capabilities.canEditTarget &&
capabilities.canConfigureTarget &&
Boolean(onConfigureTarget);

return (
<div className="h-full flex flex-col bg-background">
<div className="flex-1 overflow-hidden">
<ResizablePanelGroup direction="horizontal" className="h-full">
<ResizablePanel defaultSize={50} minSize={30}>
<OAuthSequenceDiagram
flowState={flowState}
registrationStrategy={registrationStrategy}
protocolVersion={protocolVersion}
focusedStep={focusedStep}
hasProfile={hasProfile}
onConfigure={canConfigureTarget ? onConfigureTarget : undefined}
/>
</ResizablePanel>

<ResizableHandle withHandle />

<ResizablePanel defaultSize={50} minSize={20} maxSize={50}>
<OAuthFlowLogger
oauthFlowState={flowState}
onClearLogs={onClearLogs}
onClearHttpHistory={onClearHttpHistory}
activeStep={focusedStep ?? flowState.currentStep}
onFocusStep={(step) => onFocusStep(step)}
hasProfile={hasProfile}
summary={summary}
actions={{
onConfigure: onConfigureTarget,
showConfigureTarget: canConfigureTarget,
showEditTarget: canEditTarget,
onReset,
onContinue,
continueLabel,
continueDisabled,
resetDisabled: !hasProfile || flowState.isInitiatingAuth,
onConnectServer: capabilities.canApplyTokens
? onApplyTokens
: undefined,
onRefreshTokens: capabilities.canRefreshTokens
? onRefreshTokens
: undefined,
isApplyingTokens,
}}
/>
</ResizablePanel>
</ResizablePanelGroup>
</div>

{authModal.authorizationUrl && (
<OAuthAuthorizationModal
open={authModal.open}
onOpenChange={authModal.onOpenChange}
authorizationUrl={authModal.authorizationUrl}
/>
)}

{capabilities.canRefreshTokens && refreshModal && (
<RefreshTokensConfirmModal
open={refreshModal.open}
onOpenChange={refreshModal.onOpenChange}
serverName={refreshModal.serverName}
onConfirm={refreshModal.onConfirm}
isLoading={refreshModal.isLoading}
/>
)}
</div>
);
}
66 changes: 39 additions & 27 deletions mcpjam-inspector/client/src/components/oauth/OAuthFlowLogger.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,14 +46,16 @@ interface OAuthFlowLoggerProps {
customHeadersCount?: number;
};
actions?: {
onConfigure?: () => void;
onReset?: () => void;
onContinue?: () => void;
onConfigure?: () => void | Promise<void>;
showConfigureTarget?: boolean;
showEditTarget?: boolean;
onReset?: () => void | Promise<void>;
onContinue?: () => void | Promise<void>;
continueLabel?: string;
continueDisabled?: boolean;
resetDisabled?: boolean;
onConnectServer?: () => void;
onRefreshTokens?: () => void;
onConnectServer?: () => void | Promise<void>;
onRefreshTokens?: () => void | Promise<void>;
isApplyingTokens?: boolean;
};
}
Expand Down Expand Up @@ -273,26 +275,38 @@ export function OAuthFlowLogger({
}
};

const showConfigureTarget =
actions?.showConfigureTarget !== false && Boolean(actions?.onConfigure);
const showEditTarget =
actions?.showEditTarget !== false && Boolean(actions?.onConfigure);

return (
<div className="h-full border-l border-border flex flex-col">
<div className="bg-muted/30 border-b border-border px-4 py-3 space-y-3">
{summary && hasProfile && (
<>
{/* Top row: Server URL with Edit, and Reset/Continue on right */}
<div className="flex items-center gap-2">
<button
onClick={actions?.onConfigure}
disabled={!actions?.onConfigure}
className="min-w-0 flex-1 flex items-center gap-2 text-left border border-border hover:border-foreground/30 bg-background rounded-md px-3 py-2 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed group"
>
<p className="text-sm font-medium text-foreground break-all flex-1">
{summary.serverUrl || summary.description}
</p>
<span className="flex items-center gap-1 text-xs text-muted-foreground group-hover:text-foreground shrink-0">
<Pencil className="h-3 w-3" />
Edit
</span>
</button>
{showEditTarget ? (
<button
onClick={actions?.onConfigure}
className="min-w-0 flex-1 flex items-center gap-2 text-left border border-border hover:border-foreground/30 bg-background rounded-md px-3 py-2 transition-colors cursor-pointer group"
>
<p className="text-sm font-medium text-foreground break-all flex-1">
{summary.serverUrl || summary.description}
</p>
<span className="flex items-center gap-1 text-xs text-muted-foreground group-hover:text-foreground shrink-0">
<Pencil className="h-3 w-3" />
Edit
</span>
</button>
) : (
<div className="min-w-0 flex-1 border border-border bg-background rounded-md px-3 py-2">
<p className="text-sm font-medium text-foreground break-all">
{summary.serverUrl || summary.description}
</p>
</div>
)}
<div className="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
Expand Down Expand Up @@ -378,13 +392,11 @@ export function OAuthFlowLogger({
<p className="text-sm text-muted-foreground">
{summary.description}
</p>
<Button
size="sm"
onClick={actions?.onConfigure}
disabled={!actions?.onConfigure}
>
Configure Target
</Button>
{showConfigureTarget && (
<Button size="sm" onClick={actions?.onConfigure}>
Configure Target
</Button>
)}
</div>
)}
</div>
Expand Down Expand Up @@ -449,8 +461,8 @@ export function OAuthFlowLogger({
</li>
</ol>
</div>
{actions?.onConfigure && (
<Button onClick={actions.onConfigure}>
{showConfigureTarget && (
<Button onClick={() => actions?.onConfigure?.()}>
Configure Target
</Button>
)}
Expand Down
Loading