306 lines
9.5 KiB
TypeScript
306 lines
9.5 KiB
TypeScript
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>
|
|
);
|
|
}; |