mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-06 07:12:12 +03:00
* docs: add design spec for GHE Copilot provider
* feat(mitm): add GHE Copilot target descriptor
* feat(executors): add GheCopilotExecutor for GHE Copilot
* feat(executors): register GheCopilotExecutor in factory
* feat(providers): add ghe-copilot provider with gheUrl validation
* feat(providers): add ghe-copilot to OAUTH_PROVIDERS and enforce HTTPS gheUrl validation
* test(ghe-copilot): add unit tests for GheCopilotExecutor and GHE_COPILOT_TARGET
* feat: complete GHE Copilot provider implementation
* feat: register ghe-copilot provider in registry
Add GHE Copilot registry entry (executor: "ghe-copilot") so the
provider is resolvable by the API routes and gets the same model
catalog as github Copilot.
* feat: wire ghe-copilot into OAuth flow with per-connection gheUrl
- Add gheCopilot OAuth provider (device-code flow targeting GHE host)
- Register in OAuth PROVIDERS map
- Thread gheUrl from query param → device-code request → poll →
postExchange → providerSpecificData so the GHE host is used end-to-end
- Restore corrupted src/lib/oauth/providers/github.ts from HEAD
* feat: add ghe-copilot device-code UI with gheUrl input
- Route ghe-copilot through the device-code OAuth branch (was falling
through to browser OAuth → "Browser OAuth unavailable" error)
- Add a gheUrl collection step so the enterprise host is supplied before
the device-code request, and thread it into /device-code + /poll
* fix: thread gheUrl through GHE Copilot pollToken + postExchange
pollToken read gheUrl from config (GITHUB_CONFIG, which has none) and
threw "gheUrl is required" on every poll — the connection hung forever
after device authorization. Now reads gheUrl from extraData (passed by
the route), and postExchange carries it forward into mapTokens so it is
persisted in providerSpecificData for the executor.
* fix: GHE Copilot chat routing + account test
- Capture endpoints.proxy from the GHE token response and store it as
copilotProxyUrl; route chat/responses traffic to that enterprise host
instead of the static gheUrl/chat/completions path (was 406/404).
- Always route GHE Copilot to /chat/completions (GHE proxy 404s on
/responses); the Responses API is served via the chat transformer.
- Strip the ghe-copilot/ prefix from the upstream model id.
- Remove openai-responses targetFormat from GHE models so chatCore does
not run the Responses transformer (which dropped `messages`).
- Add ghe-copilot to OAUTH_TEST_CONFIG (account test was "unsupported").
- Register executor in eslint suppressions.
* fix: drop stream:false for GHE Copilot
The GHE Copilot proxy rejects `stream: false` ("stream": false is not
supported). Only forward the flag when actually streaming; omit it
otherwise.
* fix: force stream:true upstream for GHE Copilot (streaming-only proxy)
The GHE Copilot proxy rejects `stream: false`. forceStream:true in the
registry makes chatCore pass upstreamStream=true, but GithubExecutor
.transformRequest ignores the stream arg (void stream) and keeps the
client's stream:false. Override transformRequest in GheCopilotExecutor to
force stream:true so the proxy accepts the request; chatCore drains the
SSE back to JSON for non-stream clients.
* fix: GHE Copilot live model discovery from copilotProxyUrl/models
- Add fetchGheCopilotModels/parseGheCopilotModels using enterprise proxy URL
and { models: [{ name }] } response shape (no static allowlist)
- Wire ghe-copilot into models-import route; use plain fetch (safeOutboundFetch
header guard strips the copilot bearer token -> 403)
- Import now returns real enterprise models (copilot-nes-oct, etc.) and chat
resolves them correctly
* fix: GHE Copilot uses endpoints.api host for chat + model discovery
The GHE token endpoint returns two hosts:
- endpoints.api (copilotApiUrl) -> chat/completions + real chat model
catalog, shape { data: [{ id }] }
- endpoints.proxy (copilotProxyUrl) -> NES/autocomplete/instant-apply only,
shape { models: [{ name }] }
We were routing chat AND model discovery to endpoints.proxy, so import only
returned completion models (copilot-nes-*, instant-apply) and never the real
chat models (claude-*, gpt-*, gemini-*).
- Executor: capture endpoints.api as copilotApiUrl; buildUrl prefers it
- OAuth postExchange/mapTokens: persist copilotApiUrl from endpoints.api
- Model discovery: fetch from copilotApiUrl/models, parse { data:[{id}] }
(and proxy { models:[{name}] }) shapes, no allowlist
- All traffic stays on the configured GHE host (deutschebahn.ghe.com),
never api.githubcopilot.com
Verified: import returns 28 real chat models; chat with gpt-4o streams OK.
* feat(providers): finalize GHE Copilot implementation and add changelog fragment
* fix(providers): resolve ghe-copilot no-explicit-any + complexity ratchet
- Replace the 6 explicit `any` types in GheCopilotExecutor
(transformRequest, refreshCredentials) with proper ProviderCredentials /
unknown / ExecutorLog types, and drop the config/quality/eslint-suppressions.json
allowlist entry added for them — policy requires new violations be fixed,
not frozen.
- Extract refreshViaGitHubToken() and buildRefreshedProviderSpecificData()
helpers out of refreshCredentials() to bring its cyclomatic complexity
(21) back under the repo's ratchet threshold (15); behavior unchanged.
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
* fix(oauth): unblock #7546 file-size gate for GHE Copilot OAuth provider
Extracts the GHE enterprise-URL config step from OAuthModal.tsx into a
new leaf component (src/shared/components/oauthModal/GheConfigStep.tsx)
to shrink the frozen file's own growth, and rebaselines the two
remaining irreducible wiring bumps (device-code route.ts 960->963,
OAuthModal.tsx 1030->1056) with justification comments, mirroring the
existing #7399/#6636 precedent on this same file.
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
* chore(ghe-copilot): drop planning spec from docs/ and revert out-of-scope eslint bump
Planning artifacts live outside the repo tree; package.json/lock restored to the
release state (the eslint patch bump was unrelated drift from the fork's history).
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
* fix(oauth): validate gheUrl (HTTPS-only) at both raw entry points of the device-code flow
Applies the PR's existing providerSpecificData HTTPS rule to the OAuth route's
searchParams and device-flow extraData entry points, rejecting malformed or
non-HTTPS enterprise URLs with 400 before any upstream fetch. Private-IP hosts
stay allowed by design — on-prem GHE Server is the primary use case.
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
* chore(quality): extend oauth route file-size note for the gheUrl validation guards (963->970)
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
---------
Co-authored-by: Alexander Helm <alexander.helm@deutschebahn.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com>
Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouzapw@users.noreply.github.com>
Co-authored-by: hppsc1215 <hppsc1215@users.noreply.github.com>
1056 lines
41 KiB
TypeScript
1056 lines
41 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
|
import { useTranslations } from "next-intl";
|
|
import Modal from "./Modal";
|
|
import Button from "./Button";
|
|
import Input from "./Input";
|
|
import LinkifiedText from "./LinkifiedText";
|
|
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
|
import { parseResponseBody, getErrorMessage } from "@/shared/utils/api";
|
|
import { isCredentialBlob, submitCredentialBlob } from "@/shared/components/oauthBlobSubmit";
|
|
import {
|
|
looksLikeCodexSessionJson,
|
|
parseCodexSessionJson,
|
|
} from "@/lib/oauth/utils/codexSessionImport";
|
|
import GheConfigStep from "@/shared/components/oauthModal/GheConfigStep";
|
|
|
|
const GOOGLE_OAUTH_PROVIDERS = new Set(["antigravity", "agy"]);
|
|
|
|
/** Providers that use a local callback server on a random port (PKCE browser flow). */
|
|
const PKCE_CALLBACK_SERVER_PROVIDERS = new Set(["codex", "xai-oauth"]);
|
|
|
|
/**
|
|
* Phase 1 hotfix (2026-05-29): windsurf & devin-cli only support import-token.
|
|
* Their PKCE flow targeting app.devin.ai/editor/signin returned 404 post-rebrand.
|
|
* Phase 2 will reintroduce browser login via Firebase OAuth + RegisterUser.
|
|
* Spec: _tasks/superpowers/specs/2026-05-29-windsurf-login-fix-design.md.
|
|
*/
|
|
const IMPORT_TOKEN_ONLY_PROVIDERS = new Set(["windsurf", "devin-cli", "grok-cli"]);
|
|
|
|
// POST a bare Codex access token to the access-token-only import endpoint
|
|
// (#1290); shared by the bare-JWT and session-JSON paste branches (#6636).
|
|
async function submitCodexAccessToken(
|
|
accessToken: string,
|
|
name: string | undefined,
|
|
setStep: (s: string) => void,
|
|
onSuccess?: () => void
|
|
): Promise<void> {
|
|
const res = await fetch("/api/oauth/codex/import-token", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ accessToken, name }),
|
|
});
|
|
const data = (await parseResponseBody(res)) as Record<string, unknown>;
|
|
if (!res.ok) {
|
|
throw new Error(getErrorMessage(data, res.status, "Failed to import access token"));
|
|
}
|
|
setStep("success");
|
|
onSuccess?.();
|
|
}
|
|
|
|
type OAuthModalProps = {
|
|
isOpen: boolean;
|
|
provider?: string;
|
|
providerInfo?: { name?: string } | null;
|
|
onSuccess?: () => void;
|
|
onClose: () => void;
|
|
idcConfig?: unknown;
|
|
reauthConnection?: null | { id?: string };
|
|
};
|
|
|
|
/**
|
|
* OAuth Modal Component
|
|
* - Localhost: Auto callback via popup message
|
|
* - Remote: Manual paste callback URL
|
|
*/
|
|
export default function OAuthModal({
|
|
isOpen,
|
|
provider,
|
|
providerInfo,
|
|
onSuccess,
|
|
onClose,
|
|
idcConfig,
|
|
reauthConnection,
|
|
}: OAuthModalProps) {
|
|
const t = useTranslations("oauthModal");
|
|
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 [gheUrl, setGheUrl] = useState("");
|
|
const [polling, setPolling] = useState(false);
|
|
// API-key paste mode: for providers that accept a token directly (windsurf, devin-cli)
|
|
const [showPasteToken, setShowPasteToken] = useState(
|
|
provider === "windsurf" || provider === "devin-cli" || provider === "grok-cli"
|
|
);
|
|
const [pasteToken, setPasteToken] = useState("");
|
|
const [savingToken, setSavingToken] = useState(false);
|
|
|
|
const supportsTokenPaste =
|
|
provider === "windsurf" || provider === "devin-cli" || provider === "grok-cli";
|
|
// Phase 1 hotfix (2026-05-29): windsurf/devin-cli are import-token-only.
|
|
// Hide the "Browser Login" tab — Phase 2 will restore it via Firebase OAuth.
|
|
const importTokenOnly = IMPORT_TOKEN_ONLY_PROVIDERS.has(provider);
|
|
const popupRef = useRef(null);
|
|
const { copied, copy } = useCopyToClipboard();
|
|
const deviceVerificationUrl =
|
|
deviceData?.verification_uri_complete || deviceData?.verification_uri || "";
|
|
|
|
// Client-only runtime values
|
|
const runtimeLocation = useMemo(() => {
|
|
if (typeof window === "undefined") {
|
|
return {
|
|
isLocalhost: false,
|
|
isTrueLocalhost: false,
|
|
placeholderUrl: "/callback?code=...",
|
|
};
|
|
}
|
|
|
|
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";
|
|
|
|
return {
|
|
isLocalhost: isLocal,
|
|
isTrueLocalhost: isTrulyLocal,
|
|
placeholderUrl: `${window.location.origin}/callback?code=...`,
|
|
};
|
|
}, []);
|
|
|
|
const { isLocalhost, isTrueLocalhost, placeholderUrl } = runtimeLocation;
|
|
const callbackProcessedRef = useRef(false);
|
|
const flowStartedRef = useRef(false);
|
|
|
|
// Define all useCallback hooks BEFORE the useEffects that reference them
|
|
|
|
// Exchange tokens
|
|
const exchangeTokens = useCallback(
|
|
async (code, state) => {
|
|
if (!authData) return;
|
|
try {
|
|
if (!authData.redirectUri || !authData.codeVerifier) {
|
|
throw new Error(
|
|
"OAuth session is incomplete (missing redirect URI or code verifier). Restart the connection and try again."
|
|
);
|
|
}
|
|
|
|
const normalizedState = typeof state === "string" && state.length > 0 ? state : undefined;
|
|
|
|
const res = await fetch(`/api/oauth/${provider}/exchange`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
code,
|
|
redirectUri: authData.redirectUri,
|
|
connectionId: reauthConnection?.id,
|
|
codeVerifier: authData.codeVerifier,
|
|
...(normalizedState ? { state: normalizedState } : {}),
|
|
}),
|
|
});
|
|
|
|
const data = (await parseResponseBody(res)) as Record<string, unknown>;
|
|
if (!res.ok) {
|
|
const errorObject =
|
|
typeof data.error === "object" && data.error !== null
|
|
? (data.error as Record<string, unknown>)
|
|
: null;
|
|
const errMsg = errorObject
|
|
? (errorObject.message as string) || JSON.stringify(errorObject)
|
|
: data.error || "Exchange failed";
|
|
const details = Array.isArray(errorObject?.details)
|
|
? (errorObject.details as Array<{ field?: string; message?: string }>)
|
|
.map((detail) => {
|
|
if (!detail?.message) return null;
|
|
return detail.field ? `${detail.field}: ${detail.message}` : detail.message;
|
|
})
|
|
.filter(Boolean)
|
|
.join("; ")
|
|
: "";
|
|
throw new Error(details ? `${errMsg} (${details})` : errMsg);
|
|
}
|
|
|
|
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.has(provider)
|
|
) {
|
|
setError(
|
|
"redirect_uri_mismatch: The default Google OAuth credentials only work on localhost. " +
|
|
"For remote use, configure your own OAuth credentials via environment variables: " +
|
|
"ANTIGRAVITY_OAUTH_CLIENT_ID and ANTIGRAVITY_OAUTH_CLIENT_SECRET" +
|
|
". See the README section 'OAuth on a Remote Server'."
|
|
);
|
|
} else {
|
|
setError(err.message);
|
|
}
|
|
setStep("error");
|
|
}
|
|
},
|
|
[authData, provider, onSuccess, reauthConnection]
|
|
);
|
|
|
|
// Save a raw API token directly (windsurf / devin-cli import-token path)
|
|
const handleSaveToken = useCallback(async () => {
|
|
const token = pasteToken.trim();
|
|
if (!token || !provider) return;
|
|
setSavingToken(true);
|
|
setError(null);
|
|
try {
|
|
// POST to /exchange with a synthetic "import_token" payload.
|
|
// The windsurf provider's mapTokens() handles a bare accessToken/apiKey field.
|
|
const res = await fetch(`/api/oauth/${provider}/import-token`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
token,
|
|
connectionId: reauthConnection?.id,
|
|
}),
|
|
});
|
|
const data = (await parseResponseBody(res)) as Record<string, unknown>;
|
|
if (!res.ok) {
|
|
const errMsg = getErrorMessage(data, res.status, "Save failed");
|
|
throw new Error(errMsg);
|
|
}
|
|
setStep("success");
|
|
onSuccess?.();
|
|
} catch (err) {
|
|
// Show error inline inside the paste-token form (don't flip to error step)
|
|
setError(err.message);
|
|
} finally {
|
|
setSavingToken(false);
|
|
}
|
|
}, [pasteToken, provider, onSuccess, reauthConnection]);
|
|
|
|
// 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,
|
|
connectionId: reauthConnection?.id,
|
|
codeVerifier,
|
|
extraData,
|
|
}),
|
|
});
|
|
|
|
const data = (await parseResponseBody(res)) as Record<string, unknown>;
|
|
|
|
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, reauthConnection]
|
|
);
|
|
|
|
// Start OAuth flow
|
|
const startOAuthFlow = useCallback(async () => {
|
|
if (!provider) return;
|
|
try {
|
|
setError(null);
|
|
|
|
// Device code flow (GitHub, Qwen, Kiro, Kimi Coding, KiloCode, GHE Copilot)
|
|
if (
|
|
provider === "github" ||
|
|
provider === "ghe-copilot" ||
|
|
provider === "qwen" ||
|
|
provider === "kiro" ||
|
|
provider === "amazon-q" ||
|
|
provider === "kimi-coding" ||
|
|
provider === "kilocode" ||
|
|
provider === "codebuddy-cn"
|
|
) {
|
|
setIsDeviceCode(true);
|
|
setStep("waiting");
|
|
|
|
// GHE Copilot needs the enterprise URL collected first (see ghe-config step)
|
|
if (provider === "ghe-copilot" && !gheUrl.trim()) {
|
|
setStep("ghe-config");
|
|
return;
|
|
}
|
|
|
|
const deviceCodeUrl = new URL(`/api/oauth/${provider}/device-code`, window.location.origin);
|
|
if (
|
|
(provider === "kiro" || provider === "amazon-q") &&
|
|
idcConfig &&
|
|
typeof idcConfig === "object"
|
|
) {
|
|
const idc = idcConfig as { startUrl?: string; region?: string };
|
|
if (typeof idc.startUrl === "string" && idc.startUrl.trim()) {
|
|
deviceCodeUrl.searchParams.set("startUrl", idc.startUrl.trim());
|
|
}
|
|
if (typeof idc.region === "string" && idc.region.trim()) {
|
|
deviceCodeUrl.searchParams.set("region", idc.region.trim());
|
|
}
|
|
}
|
|
if (provider === "ghe-copilot" && gheUrl.trim()) {
|
|
deviceCodeUrl.searchParams.set("gheUrl", gheUrl.trim());
|
|
}
|
|
|
|
const res = await fetch(deviceCodeUrl.toString());
|
|
const data = (await parseResponseBody(res)) as Record<string, unknown>;
|
|
if (!res.ok) {
|
|
const errMsg = getErrorMessage(data, res.status, "Request failed");
|
|
throw new Error(errMsg);
|
|
}
|
|
|
|
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" || provider === "amazon-q"
|
|
? {
|
|
_clientId: data._clientId,
|
|
_clientSecret: data._clientSecret,
|
|
_region: data._region,
|
|
}
|
|
: provider === "ghe-copilot" && gheUrl.trim()
|
|
? { gheUrl: gheUrl.trim() }
|
|
: null;
|
|
startPolling(data.device_code, data.codeVerifier, data.interval || 5, extraData);
|
|
return;
|
|
}
|
|
|
|
let forceManual = false;
|
|
|
|
// Claude Code and Cline OAuth flows can finish on provider-hosted pages that
|
|
// show an auth code instead of redirecting back to OmniRoute.
|
|
// Start directly in manual mode so users always have an input to paste code/url.
|
|
// zed-hosted's native-app sign-in always redirects the browser to a local
|
|
// 127.0.0.1:<port> callback that OmniRoute never listens on (the port is
|
|
// arbitrary and unrelated to the dashboard's own port) — nothing can
|
|
// auto-close the popup, so always show the manual paste-URL input.
|
|
if (provider === "claude" || provider === "cline" || provider === "zed-hosted") {
|
|
forceManual = true;
|
|
}
|
|
|
|
// PKCE callback server providers (Codex, Windsurf, Devin CLI):
|
|
// On localhost, spin up a local callback server and poll for the result.
|
|
// Codex uses a fixed port 1455; Windsurf/Devin CLI use a random OS-assigned port.
|
|
// On remote the server is unreachable — fall through to standard manual flow.
|
|
if (PKCE_CALLBACK_SERVER_PROVIDERS.has(provider)) {
|
|
if (isTrueLocalhost) {
|
|
try {
|
|
const serverRes = await fetch(`/api/oauth/${provider}/start-callback-server`);
|
|
const serverData = (await parseResponseBody(serverRes)) as Record<string, unknown>;
|
|
if (!serverRes.ok)
|
|
throw new Error(
|
|
getErrorMessage(serverData, serverRes.status, "Failed to start callback server")
|
|
);
|
|
|
|
setAuthData({ ...serverData, redirectUri: serverData.redirectUri });
|
|
setStep("waiting");
|
|
popupRef.current = window.open(serverData.authUrl, "oauth_auth");
|
|
|
|
// If browser blocked the popup, switch to manual input step immediately
|
|
if (!popupRef.current) {
|
|
setStep("input");
|
|
}
|
|
|
|
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/${provider}/poll-callback`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ connectionId: reauthConnection?.id }),
|
|
});
|
|
const pollData = (await parseResponseBody(pollRes)) as Record<string, unknown>;
|
|
|
|
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 (pkceErr) {
|
|
console.warn(
|
|
`${provider} callback server failed, falling back to manual flow`,
|
|
pkceErr
|
|
);
|
|
setPolling(false);
|
|
forceManual = true;
|
|
}
|
|
}
|
|
// Remote: fall through to standard auth code flow below
|
|
}
|
|
|
|
// Authorization code flow
|
|
// Redirect URI strategy:
|
|
// - Codex/OpenAI: always port 1455 (registered in OAuth app)
|
|
// - Windsurf/Devin CLI (remote fallback): use localhost with OmniRoute port + /auth/callback
|
|
// (on true localhost the callback server handles it; this is only reached on remote)
|
|
// - Google OAuth providers (antigravity/agy): default to loopback so the
|
|
// bundled native/desktop credentials keep working. Prefer 127.0.0.1 over
|
|
// localhost for the Google native-app handoff; Google documents that localhost
|
|
// can run into local firewall/name-resolution edge cases. The authorize route
|
|
// upgrades this to the public callback when custom Google web credentials plus
|
|
// NEXT_PUBLIC_BASE_URL or OMNIROUTE_PUBLIC_BASE_URL are configured.
|
|
// - 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 (provider === "xai-oauth") {
|
|
// xAI registers a fixed native-app loopback callback. On remote installs
|
|
// the browser cannot reach OmniRoute there, so the user pastes the
|
|
// resulting callback URL into the existing manual-flow input.
|
|
redirectUri = "http://127.0.0.1:56121/callback";
|
|
} else if (provider === "windsurf" || provider === "devin-cli") {
|
|
// Remote fallback: use OmniRoute's port with the /auth/callback path Windsurf expects.
|
|
// On true localhost this code is never reached (callback server handles the flow above).
|
|
const port = window.location.port || "20128";
|
|
redirectUri = `http://localhost:${port}/auth/callback`;
|
|
} else if (GOOGLE_OAUTH_PROVIDERS.has(provider)) {
|
|
// Google OAuth built-in credentials only accept loopback redirect URIs.
|
|
// Even in remote deployments we use loopback — user copies the callback URL manually.
|
|
const port = window.location.port || "20128";
|
|
redirectUri = `http://127.0.0.1:${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 parseResponseBody(res)) as Record<string, unknown>;
|
|
if (!res.ok) {
|
|
const errMsg = getErrorMessage(data, res.status, "Authorization failed");
|
|
throw new Error(errMsg);
|
|
}
|
|
|
|
if (!data.authUrl) {
|
|
throw new Error(
|
|
data.error ||
|
|
"Browser OAuth is unavailable for this provider in the current environment. Use the supported auth method instead."
|
|
);
|
|
}
|
|
|
|
setAuthData({ ...data, redirectUri: data.redirectUri || redirectUri });
|
|
|
|
// For non-true-localhost (LAN IPs, remote) or manual fallback: use manual input mode (user pastes callback URL)
|
|
if (!isTrueLocalhost || forceManual) {
|
|
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,
|
|
reauthConnection,
|
|
idcConfig,
|
|
gheUrl,
|
|
]);
|
|
|
|
// 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 (authData?.state && state && state !== authData.state) {
|
|
callbackProcessedRef.current = true;
|
|
setError("OAuth state mismatch. Restart the connection and try again.");
|
|
setStep("error");
|
|
return;
|
|
}
|
|
|
|
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;
|
|
let isLoopbackOrigin = false;
|
|
let isLocalhostSamePort = false;
|
|
try {
|
|
const eventUrl = new URL(event.origin);
|
|
isLoopbackOrigin = /^(localhost|127\.0\.0\.1|\[::1\]|::1)$/i.test(eventUrl.hostname);
|
|
isLocalhostSamePort = isLoopbackOrigin && eventUrl.port === currentPort;
|
|
} catch {
|
|
// Ignore malformed origins.
|
|
}
|
|
|
|
const payload = event.data?.data;
|
|
const hasMatchingState = !!authData?.state && payload?.state === authData.state;
|
|
const isGoogleLoopbackRelay =
|
|
GOOGLE_OAUTH_PROVIDERS.has(provider) && isLoopbackOrigin && hasMatchingState;
|
|
|
|
if (
|
|
event.origin !== window.location.origin &&
|
|
!isLocalhostSamePort &&
|
|
!isGoogleLoopbackRelay
|
|
) {
|
|
return;
|
|
}
|
|
if (event.data?.type === "oauth_callback") {
|
|
handleCallback(payload);
|
|
}
|
|
};
|
|
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, provider]);
|
|
|
|
// Fix #344: Detect when OAuth popup is closed without completing authorization
|
|
// Some providers (like Qoder) redirect to their own chat UI instead of sending a callback,
|
|
// leaving the modal stuck at "Waiting for Authorization" forever.
|
|
useEffect(() => {
|
|
if (step !== "waiting" || isDeviceCode || !popupRef.current) return;
|
|
|
|
let closed = false;
|
|
const popupClosedInterval = setInterval(() => {
|
|
if (callbackProcessedRef.current) {
|
|
clearInterval(popupClosedInterval);
|
|
return;
|
|
}
|
|
try {
|
|
if (popupRef.current?.closed) {
|
|
closed = true;
|
|
clearInterval(popupClosedInterval);
|
|
// Popup was closed without completing OAuth — switch to manual input mode
|
|
// so user can paste the callback URL from their browser address bar
|
|
if (step === "waiting") {
|
|
setStep("input");
|
|
}
|
|
}
|
|
} catch {
|
|
// Cross-origin access may throw — ignore
|
|
}
|
|
}, 1000);
|
|
|
|
// Safety timeout: 5 minutes
|
|
const safetyTimeout = setTimeout(
|
|
() => {
|
|
if (!callbackProcessedRef.current && step === "waiting") {
|
|
clearInterval(popupClosedInterval);
|
|
setStep("input");
|
|
}
|
|
},
|
|
5 * 60 * 1000
|
|
);
|
|
|
|
return () => {
|
|
clearInterval(popupClosedInterval);
|
|
clearTimeout(safetyTimeout);
|
|
};
|
|
}, [step, isDeviceCode]);
|
|
|
|
// Handle manual URL input
|
|
const handleManualSubmit = async () => {
|
|
try {
|
|
setError(null);
|
|
if (isCredentialBlob(callbackUrl)) {
|
|
await submitCredentialBlob(provider, callbackUrl, reauthConnection, setStep, onSuccess);
|
|
return;
|
|
}
|
|
|
|
// Codex: a bare ChatGPT access token (JWT, no refresh token) pasted
|
|
// directly instead of a callback URL/code — mirrors the grok-cli
|
|
// raw-token paste pattern. Routed through the access-token-only import
|
|
// endpoint (#1290) instead of the authorization-code exchange below.
|
|
if (provider === "codex" && /^eyJ/.test(callbackUrl.trim())) {
|
|
await submitCodexAccessToken(callbackUrl.trim(), undefined, setStep, onSuccess);
|
|
return;
|
|
}
|
|
|
|
// Codex: full session JSON from chatgpt.com/api/auth/session
|
|
// (`{user, accessToken, expires}`), not just the bare token (#6636).
|
|
if (provider === "codex" && looksLikeCodexSessionJson(callbackUrl)) {
|
|
const result = parseCodexSessionJson(JSON.parse(callbackUrl.trim()));
|
|
if (result.ok === false) {
|
|
setError(result.error);
|
|
return;
|
|
}
|
|
await submitCodexAccessToken(
|
|
result.session.accessToken,
|
|
result.session.email,
|
|
setStep,
|
|
onSuccess
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (!authData) {
|
|
throw new Error(
|
|
"OAuth session not initialized. Restart the connection flow and try again."
|
|
);
|
|
}
|
|
|
|
const input = callbackUrl.trim();
|
|
let code = null;
|
|
let state = authData?.state || null;
|
|
let errorParam = null;
|
|
let errorDescription = null;
|
|
|
|
try {
|
|
const url = new URL(input);
|
|
code = url.searchParams.get("code");
|
|
state = url.searchParams.get("state") || url.hash.replace(/^#/, "") || state;
|
|
errorParam = url.searchParams.get("error");
|
|
errorDescription = url.searchParams.get("error_description");
|
|
} catch {
|
|
// Claude Code remote auth may provide a raw "Authentication Code" like code#state.
|
|
const [rawCode, rawState] = input.split("#", 2);
|
|
code = rawCode || null;
|
|
state = rawState || state;
|
|
}
|
|
|
|
if (errorParam) {
|
|
throw new Error(errorDescription || errorParam);
|
|
}
|
|
|
|
if (!code) {
|
|
throw new Error(
|
|
"No authorization code found. Paste the callback URL or the Authentication Code."
|
|
);
|
|
}
|
|
|
|
await exchangeTokens(code, state);
|
|
} catch (err) {
|
|
setError(err.message);
|
|
setStep("error");
|
|
}
|
|
};
|
|
|
|
if (!provider || !providerInfo) return null;
|
|
|
|
return (
|
|
<Modal
|
|
isOpen={isOpen}
|
|
title={t("title", { providerName: providerInfo.name })}
|
|
onClose={onClose}
|
|
size="lg"
|
|
>
|
|
<div className="flex flex-col gap-4">
|
|
{/* Paste-token tab toggle (Windsurf / Devin CLI only).
|
|
Phase 1 hotfix: when importTokenOnly is true, hide the entire toggle —
|
|
there is no "Browser Login" tab to switch to until Phase 2 ships. */}
|
|
{supportsTokenPaste && !importTokenOnly && step !== "success" && (
|
|
<div className="flex gap-2 border-b border-border pb-3">
|
|
<button
|
|
className={`text-sm px-3 py-1 rounded-t ${!showPasteToken ? "font-semibold border-b-2 border-primary text-primary" : "text-text-muted"}`}
|
|
onClick={() => setShowPasteToken(false)}
|
|
>
|
|
Browser Login
|
|
</button>
|
|
<button
|
|
className={`text-sm px-3 py-1 rounded-t ${showPasteToken ? "font-semibold border-b-2 border-primary text-primary" : "text-text-muted"}`}
|
|
onClick={() => setShowPasteToken(true)}
|
|
>
|
|
Paste API Key
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Paste-token form (Windsurf / Devin CLI) */}
|
|
{supportsTokenPaste && showPasteToken && step !== "success" && (
|
|
<div className="flex flex-col gap-3">
|
|
<p className="text-sm text-text-muted">
|
|
{provider === "windsurf"
|
|
? 'In the Windsurf / VS Code IDE, run the "Windsurf: Provide Auth Token" command from the command palette (or click the Jupyter "Get Windsurf Authentication Token" button), then copy the shown token and paste it below. Opening windsurf.com/show-auth-token directly only shows a "Redirecting" page — the IDE must initiate the flow.'
|
|
: provider === "grok-cli"
|
|
? 'Paste your Grok Build JWT token from ~/.grok/auth.json (the "key" field value). You can get it by running `grok login` in your terminal.'
|
|
: 'Provide your WINDSURF_API_KEY (obtained via `devin auth login`, or via the Windsurf IDE "Windsurf: Provide Auth Token" command).'}
|
|
</p>
|
|
<Input
|
|
value={pasteToken}
|
|
onChange={(e) => setPasteToken(e.target.value)}
|
|
placeholder={provider === "grok-cli" ? "eyJ..." : "ws-..."}
|
|
type="password"
|
|
label={provider === "grok-cli" ? "JWT Token" : "API Key / Token"}
|
|
/>
|
|
{error && <p className="text-sm text-red-500">{error}</p>}
|
|
<div className="flex gap-2">
|
|
<Button
|
|
onClick={handleSaveToken}
|
|
fullWidth
|
|
disabled={!pasteToken.trim() || savingToken}
|
|
>
|
|
{savingToken ? "Saving…" : "Save Connection"}
|
|
</Button>
|
|
<Button onClick={onClose} variant="ghost" fullWidth>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* OAuth flow steps — hidden when paste-token mode is active */}
|
|
{(!supportsTokenPaste || !showPasteToken) && (
|
|
<>
|
|
{/* GHE Copilot: collect the GitHub Enterprise base URL before starting */}
|
|
{provider === "ghe-copilot" && step === "ghe-config" && (
|
|
<GheConfigStep
|
|
gheUrl={gheUrl}
|
|
setGheUrl={setGheUrl}
|
|
error={error}
|
|
setError={setError}
|
|
startOAuthFlow={startOAuthFlow}
|
|
/>
|
|
)}
|
|
|
|
{/* 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">{t("waiting")}</h3>
|
|
<p className="text-sm text-text-muted mb-2">{t("completeAuthInPopup")}</p>
|
|
<p className="text-xs text-text-muted mb-4 opacity-70">{t("popupClosedHint")}</p>
|
|
<Button variant="ghost" onClick={() => setStep("input")}>
|
|
{t("popupBlocked")}
|
|
</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">{t("deviceCodeVisitUrl")}</p>
|
|
<div className="bg-sidebar p-4 rounded-lg mb-4">
|
|
<p className="text-xs text-text-muted mb-1">{t("deviceCodeVerificationUrl")}</p>
|
|
<div className="flex items-center gap-2">
|
|
<code className="flex-1 text-sm break-all">{deviceVerificationUrl}</code>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
icon={copied === "verify_url" ? "check" : "content_copy"}
|
|
onClick={() => copy(deviceVerificationUrl, "verify_url")}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="bg-primary/10 p-4 rounded-lg">
|
|
<p className="text-xs text-text-muted mb-1">{t("deviceCodeYourCode")}</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>
|
|
{t("deviceCodeWaiting")}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* Manual Input Step */}
|
|
{step === "input" && !isDeviceCode && (
|
|
<>
|
|
<div className="space-y-4">
|
|
{/* Remote/LAN server info for Google OAuth providers */}
|
|
{!isTrueLocalhost && GOOGLE_OAUTH_PROVIDERS.has(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>
|
|
{t.rich("googleOAuthWarning", {
|
|
code: (c) => <code className="font-mono">{c}</code>,
|
|
a: (c) => (
|
|
<a
|
|
href="https://github.com/diegosouzapw/OmniRoute#oauth-on-a-remote-server"
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="underline"
|
|
>
|
|
{c}
|
|
</a>
|
|
),
|
|
})}
|
|
</strong>
|
|
</div>
|
|
)}
|
|
{/* Actionable remote paste instruction — shown for ALL remote providers,
|
|
including Google OAuth (antigravity/agy). The Google
|
|
loopback creds redirect to 127.0.0.1:<port>/callback, which on a
|
|
remotely-accessed dashboard lands on the operator's own machine and
|
|
shows a "can't reach this page" error. That is expected: the URL bar
|
|
still carries ?code=…, and pasting it below completes the login. Before
|
|
this, Google providers only saw the discouraging loopback warning and
|
|
never the "copy the URL and paste it" step, so remote login appeared to
|
|
hang. */}
|
|
{!isTrueLocalhost && (
|
|
<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>
|
|
{t("remoteAccessInfo")}
|
|
</div>
|
|
)}
|
|
<div>
|
|
<p className="text-sm font-medium mb-2">{t("step1OpenUrl")}</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")}
|
|
>
|
|
{t("copy")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<p className="text-sm font-medium mb-2">{t("step2PasteCallback")}</p>
|
|
<p className="text-xs text-text-muted mb-2">
|
|
{t.rich("step2Hint", {
|
|
code: (c) => <code className="font-mono">{c}</code>,
|
|
})}
|
|
</p>
|
|
<Input
|
|
value={callbackUrl}
|
|
onChange={(e) => setCallbackUrl(e.target.value)}
|
|
placeholder={
|
|
provider === "claude" || provider === "cline"
|
|
? "code#state or /callback?code=..."
|
|
: placeholderUrl
|
|
}
|
|
className="font-mono text-xs"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex gap-2">
|
|
<Button
|
|
onClick={handleManualSubmit}
|
|
fullWidth
|
|
disabled={!callbackUrl || (!authData && !isCredentialBlob(callbackUrl))}
|
|
>
|
|
{t("connect")}
|
|
</Button>
|
|
<Button onClick={onClose} variant="ghost" fullWidth>
|
|
{t("cancel")}
|
|
</Button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* Success Step — shown for both OAuth and paste-token flows */}
|
|
{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">{t("success")}</h3>
|
|
<p className="text-sm text-text-muted mb-4">
|
|
{t("successMessage", { providerName: providerInfo.name })}
|
|
</p>
|
|
<Button onClick={onClose} fullWidth>
|
|
{t("done")}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Error Step — OAuth errors only; paste-token errors shown inline */}
|
|
{step === "error" && !showPasteToken && (
|
|
<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">{t("error")}</h3>
|
|
<p className="text-sm text-red-600 mb-4">
|
|
<LinkifiedText text={error} />
|
|
</p>
|
|
<div className="flex gap-2">
|
|
<Button onClick={startOAuthFlow} variant="secondary" fullWidth>
|
|
{t("tryAgain")}
|
|
</Button>
|
|
<Button onClick={onClose} variant="ghost" fullWidth>
|
|
{t("cancel")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Modal>
|
|
);
|
|
}
|