mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-04 06:12:10 +03:00
When accessing OmniRoute via LAN IP (192.168.x), the OAuth popup callback can't resolve localhost back to the VPS. Now shows manual paste input with warning banner instead of waiting indefinitely.
624 lines
23 KiB
TypeScript
624 lines
23 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useRef, useCallback } from "react";
|
|
import PropTypes from "prop-types";
|
|
import { Modal, Button, Input } from "@/shared/components";
|
|
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
|
|
|
/**
|
|
* OAuth Modal Component
|
|
* - Localhost: Auto callback via popup message
|
|
* - Remote: Manual paste callback URL
|
|
*/
|
|
export default function OAuthModal({
|
|
isOpen,
|
|
provider,
|
|
providerInfo,
|
|
onSuccess,
|
|
onClose,
|
|
idcConfig,
|
|
}: any) {
|
|
const [step, setStep] = useState("waiting"); // waiting | input | success | error
|
|
const [authData, setAuthData] = useState(null);
|
|
const [callbackUrl, setCallbackUrl] = useState("");
|
|
const [error, setError] = useState(null);
|
|
const [isDeviceCode, setIsDeviceCode] = useState(false);
|
|
const [deviceData, setDeviceData] = useState(null);
|
|
const [polling, setPolling] = useState(false);
|
|
const popupRef = useRef(null);
|
|
const { copied, copy } = useCopyToClipboard();
|
|
|
|
// State for client-only values to avoid hydration mismatch
|
|
const [isLocalhost, setIsLocalhost] = useState(false);
|
|
const [placeholderUrl, setPlaceholderUrl] = useState("/callback?code=...");
|
|
const callbackProcessedRef = useRef(false);
|
|
const flowStartedRef = useRef(false);
|
|
|
|
// Detect if running on true localhost vs LAN IP (client-side only)
|
|
// - True localhost (127.0.0.1/localhost): popup auto-callback works
|
|
// - LAN IPs (192.168.x, 10.x, 172.x): redirect URI uses localhost but callback
|
|
// won't resolve back to the VPS, so use manual paste mode
|
|
const [isTrueLocalhost, setIsTrueLocalhost] = useState(false);
|
|
useEffect(() => {
|
|
if (typeof window !== "undefined") {
|
|
const hostname = window.location.hostname;
|
|
const isLocal =
|
|
hostname === "localhost" ||
|
|
hostname === "127.0.0.1" ||
|
|
hostname.startsWith("192.168.") ||
|
|
hostname.startsWith("10.") ||
|
|
/^172\.(1[6-9]|2\d|3[01])\./.test(hostname);
|
|
const isTrulyLocal = hostname === "localhost" || hostname === "127.0.0.1";
|
|
setIsLocalhost(isLocal);
|
|
setIsTrueLocalhost(isTrulyLocal);
|
|
setPlaceholderUrl(`${window.location.origin}/callback?code=...`);
|
|
}
|
|
}, []);
|
|
|
|
// Define all useCallback hooks BEFORE the useEffects that reference them
|
|
|
|
// Google OAuth providers that only accept pre-registered localhost redirect URIs
|
|
const GOOGLE_OAUTH_PROVIDERS = ["antigravity", "gemini-cli"];
|
|
|
|
// Exchange tokens
|
|
const exchangeTokens = useCallback(
|
|
async (code, state) => {
|
|
if (!authData) return;
|
|
try {
|
|
const res = await fetch(`/api/oauth/${provider}/exchange`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
code,
|
|
redirectUri: authData.redirectUri,
|
|
codeVerifier: authData.codeVerifier,
|
|
state,
|
|
}),
|
|
});
|
|
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error);
|
|
|
|
setStep("success");
|
|
onSuccess?.();
|
|
} catch (err) {
|
|
// Provide actionable guidance for redirect_uri_mismatch on Google OAuth providers
|
|
if (
|
|
err.message?.toLowerCase().includes("redirect_uri_mismatch") &&
|
|
GOOGLE_OAUTH_PROVIDERS.includes(provider)
|
|
) {
|
|
setError(
|
|
"redirect_uri_mismatch: As credenciais padrão do Google OAuth só funcionam em localhost. " +
|
|
"Para uso remoto, configure suas próprias credenciais OAuth nas variáveis de ambiente: " +
|
|
(provider === "antigravity"
|
|
? "ANTIGRAVITY_OAUTH_CLIENT_ID e ANTIGRAVITY_OAUTH_CLIENT_SECRET"
|
|
: "GEMINI_OAUTH_CLIENT_ID e GEMINI_OAUTH_CLIENT_SECRET") +
|
|
". Veja o README, seção 'OAuth em Servidor Remoto'."
|
|
);
|
|
} else {
|
|
setError(err.message);
|
|
}
|
|
setStep("error");
|
|
}
|
|
},
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
[authData, provider, onSuccess]
|
|
);
|
|
|
|
// Poll for device code token
|
|
const startPolling = useCallback(
|
|
async (deviceCode, codeVerifier, interval, extraData) => {
|
|
setPolling(true);
|
|
const maxAttempts = 60;
|
|
|
|
for (let i = 0; i < maxAttempts; i++) {
|
|
await new Promise((r) => setTimeout(r, interval * 1000));
|
|
|
|
try {
|
|
const res = await fetch(`/api/oauth/${provider}/poll`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ deviceCode, codeVerifier, extraData }),
|
|
});
|
|
|
|
const data = await res.json();
|
|
|
|
if (data.success) {
|
|
setStep("success");
|
|
setPolling(false);
|
|
onSuccess?.();
|
|
return;
|
|
}
|
|
|
|
if (data.error === "expired_token" || data.error === "access_denied") {
|
|
throw new Error(data.errorDescription || data.error);
|
|
}
|
|
|
|
if (data.error === "slow_down") {
|
|
interval = Math.min(interval + 5, 30);
|
|
}
|
|
} catch (err) {
|
|
setError(err.message);
|
|
setStep("error");
|
|
setPolling(false);
|
|
return;
|
|
}
|
|
}
|
|
|
|
setError("Authorization timeout");
|
|
setStep("error");
|
|
setPolling(false);
|
|
},
|
|
[provider, onSuccess]
|
|
);
|
|
|
|
// Start OAuth flow
|
|
const startOAuthFlow = useCallback(async () => {
|
|
if (!provider) return;
|
|
try {
|
|
setError(null);
|
|
|
|
// Device code flow (GitHub, Qwen, Kiro, Kimi Coding, KiloCode)
|
|
if (
|
|
provider === "github" ||
|
|
provider === "qwen" ||
|
|
provider === "kiro" ||
|
|
provider === "kimi-coding" ||
|
|
provider === "kilocode"
|
|
) {
|
|
setIsDeviceCode(true);
|
|
setStep("waiting");
|
|
|
|
const res = await fetch(`/api/oauth/${provider}/device-code`);
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error);
|
|
|
|
setDeviceData(data);
|
|
|
|
// Open verification URL
|
|
const verifyUrl = data.verification_uri_complete || data.verification_uri;
|
|
if (verifyUrl) window.open(verifyUrl, "oauth_verify");
|
|
|
|
// Start polling - pass extraData for Kiro (contains _clientId, _clientSecret)
|
|
const extraData =
|
|
provider === "kiro"
|
|
? { _clientId: data._clientId, _clientSecret: data._clientSecret }
|
|
: null;
|
|
startPolling(data.device_code, data.codeVerifier, data.interval || 5, extraData);
|
|
return;
|
|
}
|
|
|
|
// Codex: on localhost use callback server on port 1455,
|
|
// on remote use standard auth code flow (callback server is unreachable)
|
|
if (provider === "codex") {
|
|
if (isLocalhost) {
|
|
// Localhost: use callback server on port 1455 + polling
|
|
try {
|
|
const serverRes = await fetch(`/api/oauth/codex/start-callback-server`);
|
|
const serverData = await serverRes.json();
|
|
if (!serverRes.ok) throw new Error(serverData.error);
|
|
|
|
setAuthData({ ...serverData, redirectUri: serverData.redirectUri });
|
|
setStep("waiting");
|
|
window.open(serverData.authUrl, "oauth_auth");
|
|
|
|
setPolling(true);
|
|
const maxAttempts = 150;
|
|
for (let i = 0; i < maxAttempts; i++) {
|
|
await new Promise((r) => setTimeout(r, 2000));
|
|
|
|
const pollRes = await fetch(`/api/oauth/codex/poll-callback`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({}),
|
|
});
|
|
const pollData = await pollRes.json();
|
|
|
|
if (pollData.success) {
|
|
setStep("success");
|
|
setPolling(false);
|
|
onSuccess?.();
|
|
return;
|
|
}
|
|
|
|
if (pollData.error && !pollData.pending) {
|
|
throw new Error(pollData.errorDescription || pollData.error);
|
|
}
|
|
}
|
|
|
|
setPolling(false);
|
|
throw new Error("Authorization timeout");
|
|
} catch (codexErr) {
|
|
setPolling(false);
|
|
setStep("input");
|
|
setError(codexErr.message + " — You can paste the callback URL manually below.");
|
|
}
|
|
return;
|
|
}
|
|
// Remote: fall through to standard auth code flow below
|
|
}
|
|
|
|
// Authorization code flow
|
|
// Redirect URI strategy:
|
|
// - Codex/OpenAI: always port 1455 (registered in OAuth app)
|
|
// - Google OAuth providers (antigravity, gemini-cli): always localhost, regardless of
|
|
// where OmniRoute is hosted — Google only accepts pre-registered localhost URIs with
|
|
// the built-in credentials. Remote users must configure their own credentials.
|
|
// - Other providers on remote: use actual origin (supports PUBLIC_URL env var)
|
|
// - Localhost: use localhost:port
|
|
let redirectUri: string;
|
|
if (provider === "codex" || provider === "openai") {
|
|
redirectUri = "http://localhost:1455/auth/callback";
|
|
} else if (GOOGLE_OAUTH_PROVIDERS.includes(provider)) {
|
|
// Google OAuth built-in credentials only accept localhost redirect URIs.
|
|
// Even in remote deployments we use localhost — user copies the callback URL manually.
|
|
const port = window.location.port || "20128";
|
|
redirectUri = `http://localhost:${port}/callback`;
|
|
} else if (!isLocalhost) {
|
|
// Behind reverse proxy: use actual origin (e.g., https://omniroute.example.com/callback)
|
|
// Supports PUBLIC_URL env var override, or falls back to window.location.origin.
|
|
const publicUrl = process.env.NEXT_PUBLIC_BASE_URL;
|
|
const origin =
|
|
publicUrl && publicUrl !== "http://localhost:20128"
|
|
? publicUrl.replace(/\/$/, "")
|
|
: window.location.origin;
|
|
redirectUri = `${origin}/callback`;
|
|
} else {
|
|
const port = window.location.port || (window.location.protocol === "https:" ? "443" : "80");
|
|
redirectUri = `http://localhost:${port}/callback`;
|
|
}
|
|
|
|
const res = await fetch(
|
|
`/api/oauth/${provider}/authorize?redirect_uri=${encodeURIComponent(redirectUri)}`
|
|
);
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error);
|
|
|
|
setAuthData({ ...data, redirectUri });
|
|
|
|
// For non-true-localhost (LAN IPs, remote): use manual input mode (user pastes callback URL)
|
|
if (!isTrueLocalhost) {
|
|
setStep("input");
|
|
window.open(data.authUrl, "oauth_auth");
|
|
} else {
|
|
// Localhost: Open popup and wait for message
|
|
setStep("waiting");
|
|
popupRef.current = window.open(data.authUrl, "oauth_popup", "width=600,height=700");
|
|
|
|
// Check if popup was blocked
|
|
if (!popupRef.current) {
|
|
setStep("input");
|
|
}
|
|
}
|
|
} catch (err) {
|
|
setError(err.message);
|
|
setStep("error");
|
|
}
|
|
}, [provider, isLocalhost, isTrueLocalhost, startPolling, onSuccess]);
|
|
|
|
// Reset guard when modal closes
|
|
useEffect(() => {
|
|
if (!isOpen) {
|
|
flowStartedRef.current = false;
|
|
}
|
|
}, [isOpen]);
|
|
|
|
// Reset state and start OAuth when modal opens
|
|
useEffect(() => {
|
|
if (isOpen && provider) {
|
|
if (flowStartedRef.current) return; // Already started, prevent duplicate
|
|
flowStartedRef.current = true;
|
|
setAuthData(null);
|
|
setCallbackUrl("");
|
|
setError(null);
|
|
setIsDeviceCode(false);
|
|
setDeviceData(null);
|
|
setPolling(false);
|
|
// Auto start OAuth
|
|
startOAuthFlow();
|
|
}
|
|
}, [isOpen, provider, startOAuthFlow]);
|
|
|
|
// Listen for OAuth callback via multiple methods
|
|
useEffect(() => {
|
|
if (!authData) return;
|
|
callbackProcessedRef.current = false; // Reset when authData changes
|
|
|
|
// Handler for callback data - only process once
|
|
const handleCallback = async (data) => {
|
|
if (callbackProcessedRef.current) return; // Already processed
|
|
|
|
const { code, state, error: callbackError, errorDescription } = data;
|
|
|
|
if (callbackError) {
|
|
callbackProcessedRef.current = true;
|
|
setError(errorDescription || callbackError);
|
|
setStep("error");
|
|
return;
|
|
}
|
|
|
|
if (code) {
|
|
callbackProcessedRef.current = true;
|
|
await exchangeTokens(code, state);
|
|
}
|
|
};
|
|
|
|
// Method 1: postMessage from popup
|
|
const handleMessage = (event) => {
|
|
// Accept same-origin OR localhost with same port (remote access scenario:
|
|
// dashboard at 192.168.x:port, callback redirects to localhost:port)
|
|
const currentPort = window.location.port;
|
|
const isLocalhostSamePort =
|
|
event.origin.match(/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/) &&
|
|
new URL(event.origin).port === currentPort;
|
|
if (event.origin !== window.location.origin && !isLocalhostSamePort) return;
|
|
if (event.data?.type === "oauth_callback") {
|
|
handleCallback(event.data.data);
|
|
}
|
|
};
|
|
window.addEventListener("message", handleMessage);
|
|
|
|
// Method 2: BroadcastChannel
|
|
let channel;
|
|
try {
|
|
channel = new BroadcastChannel("oauth_callback");
|
|
channel.onmessage = (event) => handleCallback(event.data);
|
|
} catch (e) {
|
|
console.log("BroadcastChannel not supported");
|
|
}
|
|
|
|
// Method 3: localStorage event
|
|
const handleStorage = (event) => {
|
|
if (event.key === "oauth_callback" && event.newValue) {
|
|
try {
|
|
const data = JSON.parse(event.newValue);
|
|
handleCallback(data);
|
|
localStorage.removeItem("oauth_callback");
|
|
} catch (e) {
|
|
console.log("Failed to parse localStorage data");
|
|
}
|
|
}
|
|
};
|
|
window.addEventListener("storage", handleStorage);
|
|
|
|
// Also check localStorage on mount (in case callback already happened)
|
|
try {
|
|
const stored = localStorage.getItem("oauth_callback");
|
|
if (stored) {
|
|
const data = JSON.parse(stored);
|
|
// Only use if recent (within 30 seconds)
|
|
if (data.timestamp && Date.now() - data.timestamp < 30000) {
|
|
handleCallback(data);
|
|
localStorage.removeItem("oauth_callback");
|
|
}
|
|
}
|
|
} catch {
|
|
// localStorage may be unavailable or data may be malformed - ignore silently
|
|
}
|
|
|
|
return () => {
|
|
window.removeEventListener("message", handleMessage);
|
|
window.removeEventListener("storage", handleStorage);
|
|
if (channel) channel.close();
|
|
};
|
|
}, [authData, exchangeTokens]);
|
|
|
|
// Handle manual URL input
|
|
const handleManualSubmit = async () => {
|
|
try {
|
|
setError(null);
|
|
const url = new URL(callbackUrl);
|
|
const code = url.searchParams.get("code");
|
|
const state = url.searchParams.get("state");
|
|
const errorParam = url.searchParams.get("error");
|
|
|
|
if (errorParam) {
|
|
throw new Error(url.searchParams.get("error_description") || errorParam);
|
|
}
|
|
|
|
if (!code) {
|
|
throw new Error("No authorization code found in URL");
|
|
}
|
|
|
|
await exchangeTokens(code, state);
|
|
} catch (err) {
|
|
setError(err.message);
|
|
setStep("error");
|
|
}
|
|
};
|
|
|
|
if (!provider || !providerInfo) return null;
|
|
|
|
return (
|
|
<Modal isOpen={isOpen} title={`Connect ${providerInfo.name}`} onClose={onClose} size="lg">
|
|
<div className="flex flex-col gap-4">
|
|
{/* Waiting Step (Localhost - popup mode) */}
|
|
{step === "waiting" && !isDeviceCode && (
|
|
<div className="text-center py-6">
|
|
<div className="size-16 mx-auto mb-4 rounded-full bg-primary/10 flex items-center justify-center">
|
|
<span className="material-symbols-outlined text-3xl text-primary animate-spin">
|
|
progress_activity
|
|
</span>
|
|
</div>
|
|
<h3 className="text-lg font-semibold mb-2">Waiting for Authorization</h3>
|
|
<p className="text-sm text-text-muted mb-4">
|
|
Complete the authorization in the popup window.
|
|
</p>
|
|
<Button variant="ghost" onClick={() => setStep("input")}>
|
|
Popup blocked? Enter URL manually
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Device Code Flow - Waiting */}
|
|
{step === "waiting" && isDeviceCode && deviceData && (
|
|
<>
|
|
<div className="text-center py-4">
|
|
<p className="text-sm text-text-muted mb-4">
|
|
Visit the URL below and enter the code:
|
|
</p>
|
|
<div className="bg-sidebar p-4 rounded-lg mb-4">
|
|
<p className="text-xs text-text-muted mb-1">Verification URL</p>
|
|
<div className="flex items-center gap-2">
|
|
<code className="flex-1 text-sm break-all">{deviceData.verification_uri}</code>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
icon={copied === "verify_url" ? "check" : "content_copy"}
|
|
onClick={() => copy(deviceData.verification_uri, "verify_url")}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="bg-primary/10 p-4 rounded-lg">
|
|
<p className="text-xs text-text-muted mb-1">Your Code</p>
|
|
<div className="flex items-center justify-center gap-2">
|
|
<p className="text-2xl font-mono font-bold text-primary">
|
|
{deviceData.user_code}
|
|
</p>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
icon={copied === "user_code" ? "check" : "content_copy"}
|
|
onClick={() => copy(deviceData.user_code, "user_code")}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{polling && (
|
|
<div className="flex items-center justify-center gap-2 text-sm text-text-muted">
|
|
<span className="material-symbols-outlined animate-spin">progress_activity</span>
|
|
Waiting for authorization...
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* Manual Input Step */}
|
|
{step === "input" && !isDeviceCode && (
|
|
<>
|
|
<div className="space-y-4">
|
|
{/* Remote/LAN server info for Google OAuth providers */}
|
|
{!isTrueLocalhost && GOOGLE_OAUTH_PROVIDERS.includes(provider) && (
|
|
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-xs text-amber-200">
|
|
<span className="material-symbols-outlined text-sm align-middle mr-1">
|
|
warning
|
|
</span>
|
|
<strong>Acesso remoto + Google OAuth:</strong> As credenciais padrão só aceitam
|
|
redirect para <code>localhost</code>. Após autorizar, o browser tentará abrir
|
|
<code>localhost</code> — copie essa URL completa e cole abaixo. Para uso
|
|
totalmente remoto sem esse passo manual,{" "}
|
|
<a
|
|
href="https://github.com/diegosouzapw/OmniRoute#oauth-em-servidor-remoto"
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="underline"
|
|
>
|
|
configure suas próprias credenciais OAuth
|
|
</a>
|
|
.
|
|
</div>
|
|
)}
|
|
{/* Generic remote info for other providers */}
|
|
{!isTrueLocalhost && !GOOGLE_OAUTH_PROVIDERS.includes(provider) && (
|
|
<div className="rounded-lg border border-blue-500/30 bg-blue-500/10 p-3 text-xs text-blue-200">
|
|
<span className="material-symbols-outlined text-sm align-middle mr-1">info</span>
|
|
<strong>Remote access:</strong> Since you're accessing OmniRoute remotely,
|
|
after authorizing you'll see an error page (localhost not found). That's
|
|
expected — just copy the full URL from your browser's address bar and paste
|
|
it below.
|
|
</div>
|
|
)}
|
|
<div>
|
|
<p className="text-sm font-medium mb-2">Step 1: Open this URL in your browser</p>
|
|
<div className="flex gap-2">
|
|
<Input
|
|
value={authData?.authUrl || ""}
|
|
readOnly
|
|
className="flex-1 font-mono text-xs"
|
|
/>
|
|
<Button
|
|
variant="secondary"
|
|
icon={copied === "auth_url" ? "check" : "content_copy"}
|
|
onClick={() => copy(authData?.authUrl, "auth_url")}
|
|
>
|
|
Copy
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<p className="text-sm font-medium mb-2">Step 2: Paste the callback URL here</p>
|
|
<p className="text-xs text-text-muted mb-2">
|
|
After authorization, copy the full URL from your browser.
|
|
</p>
|
|
<Input
|
|
value={callbackUrl}
|
|
onChange={(e) => setCallbackUrl(e.target.value)}
|
|
placeholder={placeholderUrl}
|
|
className="font-mono text-xs"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex gap-2">
|
|
<Button onClick={handleManualSubmit} fullWidth disabled={!callbackUrl}>
|
|
Connect
|
|
</Button>
|
|
<Button onClick={onClose} variant="ghost" fullWidth>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* Success Step */}
|
|
{step === "success" && (
|
|
<div className="text-center py-6">
|
|
<div className="size-16 mx-auto mb-4 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
|
|
<span className="material-symbols-outlined text-3xl text-green-600">
|
|
check_circle
|
|
</span>
|
|
</div>
|
|
<h3 className="text-lg font-semibold mb-2">Connected Successfully!</h3>
|
|
<p className="text-sm text-text-muted mb-4">
|
|
Your {providerInfo.name} account has been connected.
|
|
</p>
|
|
<Button onClick={onClose} fullWidth>
|
|
Done
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Error Step */}
|
|
{step === "error" && (
|
|
<div className="text-center py-6">
|
|
<div className="size-16 mx-auto mb-4 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
|
|
<span className="material-symbols-outlined text-3xl text-red-600">error</span>
|
|
</div>
|
|
<h3 className="text-lg font-semibold mb-2">Connection Failed</h3>
|
|
<p className="text-sm text-red-600 mb-4">{error}</p>
|
|
<div className="flex gap-2">
|
|
<Button onClick={startOAuthFlow} variant="secondary" fullWidth>
|
|
Try Again
|
|
</Button>
|
|
<Button onClick={onClose} variant="ghost" fullWidth>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
OAuthModal.propTypes = {
|
|
isOpen: PropTypes.bool.isRequired,
|
|
provider: PropTypes.string,
|
|
providerInfo: PropTypes.shape({
|
|
name: PropTypes.string,
|
|
}),
|
|
onSuccess: PropTypes.func,
|
|
onClose: PropTypes.func.isRequired,
|
|
};
|