first commit
This commit is contained in:
306
ui/app/workspace/mcp-registry/views/oauth2Authorizer.tsx
Normal file
306
ui/app/workspace/mcp-registry/views/oauth2Authorizer.tsx
Normal file
@@ -0,0 +1,306 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { getErrorMessage } from "@/lib/store/apis/baseApi";
|
||||
import { useCompleteOAuthFlowMutation, useLazyGetOAuthConfigStatusQuery } from "@/lib/store/apis/mcpApi";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
interface OAuth2AuthorizerProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
onError: (error: string) => void;
|
||||
authorizeUrl: string;
|
||||
oauthConfigId: string;
|
||||
mcpClientId: string;
|
||||
isPerUserOauth?: boolean;
|
||||
}
|
||||
|
||||
export const OAuth2Authorizer: React.FC<OAuth2AuthorizerProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
onSuccess,
|
||||
onError,
|
||||
authorizeUrl,
|
||||
oauthConfigId,
|
||||
isPerUserOauth,
|
||||
}) => {
|
||||
const [status, setStatus] = useState<"confirm" | "pending" | "polling" | "success" | "failed">(isPerUserOauth ? "confirm" : "pending");
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const popupRef = useRef<Window | null>(null);
|
||||
const pollIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const isCompletingRef = useRef(false);
|
||||
|
||||
// RTK Query hooks
|
||||
const [getOAuthStatus] = useLazyGetOAuthConfigStatusQuery();
|
||||
const [completeOAuth] = useCompleteOAuthFlowMutation();
|
||||
|
||||
// Stop polling
|
||||
const stopPolling = useCallback(() => {
|
||||
if (pollIntervalRef.current) {
|
||||
clearInterval(pollIntervalRef.current);
|
||||
pollIntervalRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handle successful OAuth completion
|
||||
const handleOAuthComplete = useCallback(async () => {
|
||||
// Guard against concurrent calls (race between postMessage and polling)
|
||||
if (isCompletingRef.current) return;
|
||||
isCompletingRef.current = true;
|
||||
|
||||
// Close popup if still open
|
||||
if (popupRef.current && !popupRef.current.closed) {
|
||||
popupRef.current.close();
|
||||
}
|
||||
|
||||
// Call complete-oauth endpoint using RTK Query mutation
|
||||
// Use oauthConfigId instead of mcpClientId for multi-instance support
|
||||
try {
|
||||
await completeOAuth(oauthConfigId).unwrap();
|
||||
setStatus("success");
|
||||
onSuccess();
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
const errMsg = getErrorMessage(error);
|
||||
setStatus("failed");
|
||||
setErrorMessage(errMsg);
|
||||
onError(errMsg);
|
||||
}
|
||||
}, [oauthConfigId, completeOAuth, onSuccess, onClose, onError]);
|
||||
|
||||
// Handle OAuth failure
|
||||
const handleOAuthFailed = useCallback(
|
||||
(reason: string) => {
|
||||
stopPolling();
|
||||
if (popupRef.current && !popupRef.current.closed) {
|
||||
popupRef.current.close();
|
||||
}
|
||||
setStatus("failed");
|
||||
setErrorMessage(reason);
|
||||
onError(reason);
|
||||
},
|
||||
[stopPolling, onError],
|
||||
);
|
||||
|
||||
// Check OAuth status (called by postMessage or polling)
|
||||
const checkOAuthStatus = useCallback(async () => {
|
||||
try {
|
||||
const result = await getOAuthStatus(oauthConfigId).unwrap();
|
||||
|
||||
if (result.status === "authorized") {
|
||||
stopPolling();
|
||||
await handleOAuthComplete();
|
||||
} else if (result.status === "failed" || result.status === "expired") {
|
||||
handleOAuthFailed(`Authorization ${result.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error checking OAuth status:", error);
|
||||
}
|
||||
}, [oauthConfigId, getOAuthStatus, stopPolling, handleOAuthComplete, handleOAuthFailed]);
|
||||
|
||||
// Poll OAuth status
|
||||
const startPolling = useCallback(() => {
|
||||
// Clear any existing interval
|
||||
if (pollIntervalRef.current) {
|
||||
clearInterval(pollIntervalRef.current);
|
||||
}
|
||||
|
||||
pollIntervalRef.current = setInterval(async () => {
|
||||
// Check if popup is still open
|
||||
if (popupRef.current && popupRef.current.closed) {
|
||||
// Popup closed - check status before assuming cancellation
|
||||
// (OAuth callback page closes the popup after success)
|
||||
try {
|
||||
const result = await getOAuthStatus(oauthConfigId).unwrap();
|
||||
if (result.status === "authorized") {
|
||||
stopPolling();
|
||||
await handleOAuthComplete();
|
||||
} else if (result.status === "failed" || result.status === "expired") {
|
||||
stopPolling();
|
||||
handleOAuthFailed("Authorization failed");
|
||||
}
|
||||
// pending or other non-terminal: let polling continue
|
||||
} catch {
|
||||
// transient fetch error: let polling continue
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await checkOAuthStatus();
|
||||
}, 2000); // Poll every 2 seconds
|
||||
}, [checkOAuthStatus, handleOAuthFailed]);
|
||||
|
||||
// Open popup and start polling
|
||||
const openPopup = useCallback(() => {
|
||||
// Reset completion guard for each fresh OAuth attempt
|
||||
isCompletingRef.current = false;
|
||||
|
||||
// Close any existing popup
|
||||
if (popupRef.current && !popupRef.current.closed) {
|
||||
popupRef.current.close();
|
||||
}
|
||||
|
||||
// Open OAuth popup
|
||||
const width = 600;
|
||||
const height = 700;
|
||||
const left = window.screen.width / 2 - width / 2;
|
||||
const top = window.screen.height / 2 - height / 2;
|
||||
|
||||
popupRef.current = window.open(
|
||||
authorizeUrl,
|
||||
"oauth_popup",
|
||||
`width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=yes`,
|
||||
);
|
||||
|
||||
setStatus("polling");
|
||||
|
||||
// Start polling OAuth status
|
||||
startPolling();
|
||||
}, [authorizeUrl, startPolling]);
|
||||
|
||||
// Listen for postMessage from OAuth callback popup
|
||||
useEffect(() => {
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
// Verify message is from OAuth callback
|
||||
if (event.data?.type === "oauth_success") {
|
||||
// Trigger immediate status check; stopPolling is called inside
|
||||
// checkOAuthStatus only after a confirmed terminal state, so
|
||||
// transient fetch errors still allow polling to continue.
|
||||
checkOAuthStatus();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("message", handleMessage);
|
||||
return () => {
|
||||
window.removeEventListener("message", handleMessage);
|
||||
};
|
||||
}, [checkOAuthStatus]);
|
||||
|
||||
// Open popup when dialog opens (skip if waiting for user confirmation)
|
||||
useEffect(() => {
|
||||
if (open && status === "pending") {
|
||||
openPopup();
|
||||
}
|
||||
}, [open, status, openPopup]);
|
||||
|
||||
// Handle user confirming per-user OAuth test
|
||||
const handleConfirmPerUserOAuth = () => {
|
||||
setStatus("pending");
|
||||
openPopup();
|
||||
};
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
stopPolling();
|
||||
if (popupRef.current && !popupRef.current.closed) {
|
||||
popupRef.current.close();
|
||||
}
|
||||
};
|
||||
}, [stopPolling]);
|
||||
|
||||
const handleRetry = () => {
|
||||
setErrorMessage(null);
|
||||
isCompletingRef.current = false;
|
||||
if (isPerUserOauth) {
|
||||
setStatus("confirm");
|
||||
} else {
|
||||
setStatus("pending");
|
||||
openPopup();
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
stopPolling();
|
||||
isCompletingRef.current = false;
|
||||
if (popupRef.current && !popupRef.current.closed) {
|
||||
popupRef.current.close();
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open}>
|
||||
<DialogContent className="sm:max-w-md" onPointerDownOutside={(e) => e.preventDefault()} onEscapeKeyDown={(e) => e.preventDefault()}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{status === "confirm" ? "Test OAuth Configuration" : "OAuth Authorization"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{status === "confirm" && "A one-time login is needed to verify your OAuth setup."}
|
||||
{status === "pending" && "Opening authorization window..."}
|
||||
{status === "polling" && "Waiting for authorization..."}
|
||||
{status === "success" && "Authorization successful!"}
|
||||
{status === "failed" && "Authorization failed"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col items-center justify-center space-y-4">
|
||||
{status === "confirm" && (
|
||||
<>
|
||||
<div className="text-muted-foreground space-y-3 text-sm">
|
||||
<p>
|
||||
To set up this MCP server, we need to verify that your OAuth configuration is correct and discover the available tools.
|
||||
</p>
|
||||
<p>
|
||||
You will be asked to log in to the OAuth provider. This is a <strong>one-time test</strong> to confirm the setup works.
|
||||
Your credentials will <strong>not</strong> be stored or used for any other purpose.
|
||||
</p>
|
||||
<p>Once verified, each user will authenticate individually when they use this MCP server.</p>
|
||||
</div>
|
||||
<div className="flex w-full justify-end space-x-2">
|
||||
<Button onClick={handleCancel} variant="outline" data-testid="per-user-oauth-cancel">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleConfirmPerUserOAuth} data-testid="per-user-oauth-confirm">
|
||||
Continue with Test Login
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === "polling" && (
|
||||
<>
|
||||
<Loader2 className="text-secondary-foreground h-4 w-4 animate-spin" />
|
||||
<p className="text-muted-foreground text-sm">Please complete authorization in the popup window</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === "success" && (
|
||||
<>
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
|
||||
<svg className="h-6 w-6 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm text-green-600">MCP server connected successfully!</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === "failed" && (
|
||||
<>
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-red-100">
|
||||
<svg className="h-6 w-6 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm text-red-600">{errorMessage || "An error occurred"}</p>
|
||||
<Button onClick={handleRetry} variant="outline">
|
||||
Retry
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{status === "polling" && (
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button onClick={handleCancel} variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user