mirror of
https://github.com/different-ai/openwork
synced 2026-04-25 17:15:34 +02:00
927 lines
33 KiB
TypeScript
927 lines
33 KiB
TypeScript
import { For, Show, createEffect, createSignal, on, onCleanup } from "solid-js";
|
|
import { CheckCircle2, Loader2, RefreshCcw, X } from "lucide-solid";
|
|
import Button from "./button";
|
|
import TextInput from "./text-input";
|
|
import type { Client } from "../types";
|
|
import type { McpDirectoryInfo } from "../constants";
|
|
import { unwrap } from "../lib/opencode";
|
|
import { opencodeMcpAuth } from "../lib/tauri";
|
|
import { validateMcpServerName } from "../mcp";
|
|
import { t, type Language } from "../../i18n";
|
|
import { isTauriRuntime, normalizeDirectoryPath } from "../utils";
|
|
|
|
const MCP_AUTH_POLL_INTERVAL_MS = 2_000;
|
|
const MCP_AUTH_TIMEOUT_MS = 90_000;
|
|
const MCP_AUTH_DISCOVERY_TIMEOUT_MS = 15_000;
|
|
|
|
export type McpAuthModalProps = {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
onComplete: () => void | Promise<void>;
|
|
onReloadEngine?: () => void | Promise<void>;
|
|
reloadRequired?: boolean;
|
|
reloadBlocked?: boolean;
|
|
activeSessions?: Array<{ id: string; title: string }>;
|
|
isRemoteWorkspace?: boolean;
|
|
client: Client | null;
|
|
entry: McpDirectoryInfo | null;
|
|
projectDir: string;
|
|
language: Language;
|
|
onForceStopSession?: (sessionID: string) => void | Promise<void>;
|
|
};
|
|
|
|
export default function McpAuthModal(props: McpAuthModalProps) {
|
|
const translate = (key: string, replacements?: Record<string, string>) => {
|
|
let result = t(key, props.language);
|
|
if (replacements) {
|
|
Object.entries(replacements).forEach(([placeholder, value]) => {
|
|
result = result.replace(`{${placeholder}}`, value);
|
|
});
|
|
}
|
|
return result;
|
|
};
|
|
|
|
const [loading, setLoading] = createSignal(false);
|
|
const [error, setError] = createSignal<string | null>(null);
|
|
const [needsReload, setNeedsReload] = createSignal(false);
|
|
const [alreadyConnected, setAlreadyConnected] = createSignal(false);
|
|
const [authInProgress, setAuthInProgress] = createSignal(false);
|
|
const [statusChecking, setStatusChecking] = createSignal(false);
|
|
const [reloadNotice, setReloadNotice] = createSignal<string | null>(null);
|
|
const [authorizationUrl, setAuthorizationUrl] = createSignal<string | null>(null);
|
|
const [callbackInput, setCallbackInput] = createSignal("");
|
|
const [manualAuthBusy, setManualAuthBusy] = createSignal(false);
|
|
const [cliAuthBusy, setCliAuthBusy] = createSignal(false);
|
|
const [cliAuthResult, setCliAuthResult] = createSignal<string | null>(null);
|
|
const [authUrlCopied, setAuthUrlCopied] = createSignal(false);
|
|
const [resolvedDir, setResolvedDir] = createSignal("");
|
|
const [awaitingReload, setAwaitingReload] = createSignal(false);
|
|
const [reloadStarting, setReloadStarting] = createSignal(false);
|
|
const [reloadSatisfied, setReloadSatisfied] = createSignal(false);
|
|
const [forceStopBusySessionID, setForceStopBusySessionID] = createSignal<string | null>(null);
|
|
|
|
let statusPoll: number | null = null;
|
|
let authCopyTimeout: number | null = null;
|
|
|
|
const stopStatusPolling = () => {
|
|
if (statusPoll !== null) {
|
|
window.clearInterval(statusPoll);
|
|
statusPoll = null;
|
|
}
|
|
};
|
|
|
|
onCleanup(() => stopStatusPolling());
|
|
|
|
createEffect(() => {
|
|
const normalized = normalizeDirectoryPath(props.projectDir ?? "");
|
|
const collapsed = normalized.replace(/^\/private\/tmp(?=\/|$)/, "/tmp");
|
|
setResolvedDir(collapsed);
|
|
});
|
|
|
|
onCleanup(() => {
|
|
if (authCopyTimeout !== null) {
|
|
window.clearTimeout(authCopyTimeout);
|
|
authCopyTimeout = null;
|
|
}
|
|
});
|
|
|
|
const openAuthorizationUrl = async (url: string) => {
|
|
if (isTauriRuntime()) {
|
|
const { openUrl } = await import("@tauri-apps/plugin-opener");
|
|
await openUrl(url);
|
|
return;
|
|
}
|
|
|
|
if (typeof window !== "undefined") {
|
|
window.open(url, "_blank", "noopener,noreferrer");
|
|
}
|
|
};
|
|
|
|
const handleCopyAuthorizationUrl = async () => {
|
|
const url = authorizationUrl();
|
|
if (!url) return;
|
|
try {
|
|
await navigator.clipboard.writeText(url);
|
|
setAuthUrlCopied(true);
|
|
if (authCopyTimeout !== null) {
|
|
window.clearTimeout(authCopyTimeout);
|
|
}
|
|
authCopyTimeout = window.setTimeout(() => {
|
|
setAuthUrlCopied(false);
|
|
authCopyTimeout = null;
|
|
}, 2000);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
};
|
|
|
|
const fetchMcpStatus = async (slug: string) => {
|
|
const entry = props.entry;
|
|
const client = props.client;
|
|
if (!entry || !client) return null;
|
|
|
|
try {
|
|
const directory = resolvedDir().trim();
|
|
if (!directory) return null;
|
|
const result = await client.mcp.status({ directory });
|
|
const status = result.data?.[slug] as { status?: string; error?: string } | undefined;
|
|
return status ?? null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const resolveDirectory = async () => {
|
|
const current = resolvedDir().trim();
|
|
if (current) return current;
|
|
const client = props.client;
|
|
if (!client) return "";
|
|
try {
|
|
const info = unwrap(await client.path.get());
|
|
const next = normalizeDirectoryPath(info.directory ?? "");
|
|
const collapsed = next.replace(/^\/private\/tmp(?=\/|$)/, "/tmp");
|
|
if (collapsed) {
|
|
setResolvedDir(collapsed);
|
|
}
|
|
return collapsed;
|
|
} catch {
|
|
return "";
|
|
}
|
|
};
|
|
|
|
const resolveSlug = (name: string) => validateMcpServerName(name).toLowerCase().replace(/[^a-z0-9]+/g, "-");
|
|
|
|
const waitForMcpAvailability = async (slug: string) => {
|
|
const startedAt = Date.now();
|
|
while (Date.now() - startedAt < MCP_AUTH_DISCOVERY_TIMEOUT_MS) {
|
|
const status = await fetchMcpStatus(slug);
|
|
if (status) return status;
|
|
await new Promise((resolve) => window.setTimeout(resolve, 500));
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const startStatusPolling = (slug: string) => {
|
|
if (typeof window === "undefined") return;
|
|
stopStatusPolling();
|
|
const startedAt = Date.now();
|
|
statusPoll = window.setInterval(async () => {
|
|
if (Date.now() - startedAt >= MCP_AUTH_TIMEOUT_MS) {
|
|
stopStatusPolling();
|
|
setError(translate("mcp.auth.request_timed_out"));
|
|
return;
|
|
}
|
|
|
|
const status = await fetchMcpStatus(slug);
|
|
if (status?.status === "connected") {
|
|
setAlreadyConnected(true);
|
|
setError(null);
|
|
stopStatusPolling();
|
|
}
|
|
}, MCP_AUTH_POLL_INTERVAL_MS);
|
|
};
|
|
|
|
const startAuth = async (forceRetry = false, allowAutoReload = true) => {
|
|
const entry = props.entry;
|
|
const client = props.client;
|
|
|
|
if (!entry || !client) return;
|
|
|
|
const isRemoteWorkspace = !!props.isRemoteWorkspace;
|
|
|
|
let slug = "";
|
|
try {
|
|
slug = resolveSlug(entry.name);
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : translate("mcp.auth.failed_to_start_oauth");
|
|
setError(message);
|
|
setLoading(false);
|
|
setAuthInProgress(false);
|
|
return;
|
|
}
|
|
|
|
if (!forceRetry && authInProgress()) {
|
|
return;
|
|
}
|
|
|
|
setError(null);
|
|
setNeedsReload(false);
|
|
setAlreadyConnected(false);
|
|
stopStatusPolling();
|
|
setAuthorizationUrl(null);
|
|
setCallbackInput("");
|
|
setReloadNotice(null);
|
|
setLoading(true);
|
|
setAuthInProgress(true);
|
|
|
|
try {
|
|
const directory = await resolveDirectory();
|
|
if (!directory) {
|
|
setError(translate("mcp.pick_workspace_first"));
|
|
return;
|
|
}
|
|
|
|
const statusEntry = await fetchMcpStatus(slug);
|
|
if (props.reloadRequired && !reloadSatisfied() && !statusEntry) {
|
|
setNeedsReload(true);
|
|
setReloadNotice(
|
|
props.reloadBlocked
|
|
? translate("mcp.auth.reload_blocked")
|
|
: translate("mcp.auth.reload_notice")
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (statusEntry?.status === "connected") {
|
|
setAlreadyConnected(true);
|
|
return;
|
|
}
|
|
|
|
if (!isRemoteWorkspace) {
|
|
const result = await client.mcp.auth.authenticate({
|
|
name: slug,
|
|
directory,
|
|
});
|
|
const status = unwrap(result) as { status?: string; error?: string };
|
|
|
|
if (status.status === "connected") {
|
|
setAlreadyConnected(true);
|
|
await props.onComplete();
|
|
return;
|
|
}
|
|
|
|
if (status.status === "needs_client_registration") {
|
|
setError(status.error ?? translate("mcp.auth.client_registration_required"));
|
|
} else if (status.status === "disabled") {
|
|
setError(translate("mcp.auth.server_disabled"));
|
|
} else if (status.status === "failed") {
|
|
setError(status.error ?? translate("mcp.auth.oauth_failed"));
|
|
} else {
|
|
setError(translate("mcp.auth.authorization_still_required"));
|
|
}
|
|
return;
|
|
}
|
|
|
|
const authResult = await client.mcp.auth.start({
|
|
name: slug,
|
|
directory,
|
|
});
|
|
const auth = unwrap(authResult) as { authorizationUrl?: string };
|
|
|
|
if (!auth.authorizationUrl) {
|
|
setAlreadyConnected(true);
|
|
return;
|
|
}
|
|
|
|
setAuthorizationUrl(auth.authorizationUrl);
|
|
await openAuthorizationUrl(auth.authorizationUrl);
|
|
startStatusPolling(slug);
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : translate("mcp.auth.failed_to_start_oauth");
|
|
|
|
if (message.toLowerCase().includes("does not support oauth")) {
|
|
const serverSlug = props.entry?.name.toLowerCase().replace(/[^a-z0-9]+/g, "-") ?? "server";
|
|
const canAutoReload =
|
|
allowAutoReload && !props.isRemoteWorkspace && !props.reloadBlocked && Boolean(props.onReloadEngine);
|
|
|
|
if (canAutoReload && props.onReloadEngine) {
|
|
await props.onReloadEngine();
|
|
await startAuth(true, false);
|
|
return;
|
|
}
|
|
|
|
if (props.reloadRequired && !reloadSatisfied()) {
|
|
setReloadNotice(
|
|
props.reloadBlocked
|
|
? translate("mcp.auth.reload_blocked")
|
|
: translate("mcp.auth.reload_notice")
|
|
);
|
|
} else {
|
|
setError(
|
|
`${message}\n\n` + translate("mcp.auth.oauth_not_supported_hint", { server: serverSlug })
|
|
);
|
|
}
|
|
setNeedsReload(true);
|
|
} else if (message.toLowerCase().includes("not found") || message.toLowerCase().includes("unknown")) {
|
|
setNeedsReload(true);
|
|
setError(translate("mcp.auth.try_reload_engine", { message }));
|
|
} else {
|
|
setError(message);
|
|
}
|
|
} finally {
|
|
setLoading(false);
|
|
setAuthInProgress(false);
|
|
}
|
|
};
|
|
|
|
const isInvalidRefreshToken = () => {
|
|
const message = error();
|
|
if (!message) return false;
|
|
const normalized = message.toLowerCase();
|
|
return (
|
|
normalized.includes("invalidgranterror") ||
|
|
normalized.includes("invalid refresh token") ||
|
|
normalized.includes("invalid_refresh_token")
|
|
);
|
|
};
|
|
|
|
const handleCliReauth = async () => {
|
|
const entry = props.entry;
|
|
if (!entry || cliAuthBusy()) return;
|
|
if (props.isRemoteWorkspace) return;
|
|
if (!isTauriRuntime()) return;
|
|
|
|
setCliAuthBusy(true);
|
|
setCliAuthResult(null);
|
|
|
|
try {
|
|
const result = await opencodeMcpAuth(props.projectDir, entry.name);
|
|
if (result.ok) {
|
|
setError(null);
|
|
setNeedsReload(true);
|
|
setReloadNotice(translate("mcp.auth.oauth_completed_reload"));
|
|
} else {
|
|
setCliAuthResult(result.stderr || result.stdout || translate("mcp.auth.reauth_failed"));
|
|
}
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : translate("mcp.auth.reauth_failed");
|
|
setCliAuthResult(message);
|
|
} finally {
|
|
setCliAuthBusy(false);
|
|
}
|
|
};
|
|
|
|
// Start the OAuth flow when modal opens with an entry
|
|
createEffect(
|
|
on(
|
|
() => [props.open, props.entry, props.client, props.reloadRequired] as const,
|
|
([isOpen, entry, client, reloadRequired], previous) => {
|
|
if (!isOpen || !entry || !client) {
|
|
return;
|
|
}
|
|
const previousEntry = previous?.[1];
|
|
if (!previous || previousEntry?.name !== entry.name || !previous?.[0]) {
|
|
setReloadSatisfied(false);
|
|
}
|
|
if (reloadRequired && !reloadSatisfied()) {
|
|
setAwaitingReload(true);
|
|
return;
|
|
}
|
|
// Only start auth on initial open, not on every prop change
|
|
startAuth(false);
|
|
},
|
|
{ defer: true } // Defer to avoid double-firing on mount
|
|
)
|
|
);
|
|
|
|
createEffect(() => {
|
|
if (!props.open || !awaitingReload()) return;
|
|
if (props.reloadBlocked) return;
|
|
const reloadEngine = props.onReloadEngine;
|
|
const entry = props.entry;
|
|
if (!reloadEngine || !entry || reloadStarting()) return;
|
|
|
|
void (async () => {
|
|
setReloadStarting(true);
|
|
setError(null);
|
|
setNeedsReload(false);
|
|
setReloadNotice(null);
|
|
try {
|
|
await reloadEngine();
|
|
if (!props.open) return;
|
|
const slug = resolveSlug(entry.name);
|
|
const status = await waitForMcpAvailability(slug);
|
|
if (!status) {
|
|
setAwaitingReload(false);
|
|
setNeedsReload(true);
|
|
setReloadNotice(
|
|
props.reloadBlocked
|
|
? translate("mcp.auth.reload_blocked")
|
|
: translate("mcp.auth.reload_notice")
|
|
);
|
|
return;
|
|
}
|
|
setReloadSatisfied(true);
|
|
setAwaitingReload(false);
|
|
startAuth(false, false);
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : translate("mcp.auth.reload_failed");
|
|
setAwaitingReload(false);
|
|
setNeedsReload(true);
|
|
setError(message);
|
|
} finally {
|
|
setReloadStarting(false);
|
|
}
|
|
})();
|
|
});
|
|
|
|
const handleRetry = () => {
|
|
startAuth(true);
|
|
};
|
|
|
|
const handleReopenBrowser = () => {
|
|
handleRetry();
|
|
};
|
|
|
|
const handleReloadAndRetry = async () => {
|
|
if (!props.onReloadEngine) return;
|
|
if (props.isRemoteWorkspace && typeof window !== "undefined") {
|
|
const proceed = window.confirm(translate("mcp.auth.reload_remote_confirm"));
|
|
if (!proceed) return;
|
|
}
|
|
await props.onReloadEngine();
|
|
startAuth(true);
|
|
};
|
|
|
|
const handleForceStopSession = async (sessionID: string) => {
|
|
if (!props.onForceStopSession || forceStopBusySessionID()) return;
|
|
setForceStopBusySessionID(sessionID);
|
|
try {
|
|
await props.onForceStopSession(sessionID);
|
|
} finally {
|
|
setForceStopBusySessionID(null);
|
|
}
|
|
};
|
|
|
|
const handleClose = () => {
|
|
setError(null);
|
|
setLoading(false);
|
|
setAlreadyConnected(false);
|
|
setNeedsReload(false);
|
|
setAuthInProgress(false);
|
|
setStatusChecking(false);
|
|
setAuthorizationUrl(null);
|
|
setCallbackInput("");
|
|
setManualAuthBusy(false);
|
|
setReloadNotice(null);
|
|
setCliAuthBusy(false);
|
|
setCliAuthResult(null);
|
|
setAwaitingReload(false);
|
|
setReloadStarting(false);
|
|
setReloadSatisfied(false);
|
|
setForceStopBusySessionID(null);
|
|
stopStatusPolling();
|
|
props.onClose();
|
|
};
|
|
|
|
const isBusy = () => loading() || statusChecking() || manualAuthBusy();
|
|
const isPreparingReload = () => awaitingReload() || reloadStarting();
|
|
|
|
const parseAuthCode = (value: string) => {
|
|
const trimmed = value.trim();
|
|
if (!trimmed) return null;
|
|
|
|
const match = trimmed.match(/[?&]code=([^&]+)/);
|
|
if (match) {
|
|
try {
|
|
return decodeURIComponent(match[1]);
|
|
} catch {
|
|
return match[1];
|
|
}
|
|
}
|
|
|
|
if (/^https?:\/\//i.test(trimmed) || trimmed.includes("localhost") || trimmed.includes("127.0.0.1")) {
|
|
return null;
|
|
}
|
|
|
|
return trimmed;
|
|
};
|
|
|
|
const handleManualComplete = async () => {
|
|
const entry = props.entry;
|
|
const client = props.client;
|
|
if (!entry || !client) return;
|
|
|
|
let slug = "";
|
|
try {
|
|
const safeName = validateMcpServerName(entry.name);
|
|
slug = safeName.toLowerCase().replace(/[^a-z0-9]+/g, "-");
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : translate("mcp.auth.failed_to_start_oauth");
|
|
setError(message);
|
|
return;
|
|
}
|
|
|
|
const code = parseAuthCode(callbackInput());
|
|
if (!code) {
|
|
setError(translate("mcp.auth.callback_invalid"));
|
|
return;
|
|
}
|
|
|
|
setManualAuthBusy(true);
|
|
setError(null);
|
|
stopStatusPolling();
|
|
|
|
try {
|
|
const directory = await resolveDirectory();
|
|
if (!directory) {
|
|
setError(translate("mcp.pick_workspace_first"));
|
|
return;
|
|
}
|
|
|
|
const result = await client.mcp.auth.callback({
|
|
name: slug,
|
|
directory,
|
|
code,
|
|
});
|
|
const status = unwrap(result) as { status?: string; error?: string };
|
|
if (status.status === "connected") {
|
|
setAlreadyConnected(true);
|
|
setManualAuthBusy(false);
|
|
await props.onComplete();
|
|
return;
|
|
}
|
|
|
|
if (status.status === "needs_client_registration") {
|
|
setError(status.error ?? translate("mcp.auth.client_registration_required"));
|
|
} else if (status.status === "disabled") {
|
|
setError(translate("mcp.auth.server_disabled"));
|
|
} else if (status.status === "failed") {
|
|
setError(status.error ?? translate("mcp.auth.oauth_failed"));
|
|
} else {
|
|
setError(translate("mcp.auth.authorization_still_required"));
|
|
}
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : translate("mcp.auth.oauth_failed");
|
|
setError(message);
|
|
} finally {
|
|
setManualAuthBusy(false);
|
|
}
|
|
};
|
|
|
|
const handleComplete = async () => {
|
|
const entry = props.entry;
|
|
const client = props.client;
|
|
if (!entry || !client) return;
|
|
|
|
setError(null);
|
|
setStatusChecking(true);
|
|
|
|
let slug = "";
|
|
try {
|
|
const safeName = validateMcpServerName(entry.name);
|
|
slug = safeName.toLowerCase().replace(/[^a-z0-9]+/g, "-");
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : translate("mcp.auth.failed_to_start_oauth");
|
|
setError(message);
|
|
setStatusChecking(false);
|
|
return;
|
|
}
|
|
|
|
const statusEntry = await fetchMcpStatus(slug);
|
|
if (statusEntry?.status === "connected") {
|
|
setAlreadyConnected(true);
|
|
setStatusChecking(false);
|
|
await props.onComplete();
|
|
return;
|
|
}
|
|
|
|
if (statusEntry?.status === "needs_client_registration") {
|
|
setError(statusEntry.error ?? translate("mcp.auth.client_registration_required"));
|
|
} else if (statusEntry?.status === "disabled") {
|
|
setError(translate("mcp.auth.server_disabled"));
|
|
} else if (statusEntry?.status === "failed") {
|
|
setError(statusEntry.error ?? translate("mcp.auth.oauth_failed"));
|
|
} else {
|
|
setError(translate("mcp.auth.authorization_still_required"));
|
|
}
|
|
|
|
setStatusChecking(false);
|
|
};
|
|
|
|
const serverName = () => props.entry?.name ?? "MCP Server";
|
|
|
|
return (
|
|
<Show when={props.open}>
|
|
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
|
{/* Backdrop */}
|
|
<div
|
|
class="absolute inset-0 bg-gray-1/60 backdrop-blur-sm"
|
|
onClick={handleClose}
|
|
/>
|
|
|
|
{/* Modal */}
|
|
<div class="relative w-full max-w-lg bg-gray-2 border border-gray-6 rounded-2xl shadow-2xl overflow-hidden">
|
|
{/* Header */}
|
|
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-6">
|
|
<div>
|
|
<h2 class="text-lg font-semibold text-gray-12">
|
|
{translate("mcp.auth.connect_server", { server: serverName() })}
|
|
</h2>
|
|
<p class="text-sm text-gray-11">{translate("mcp.auth.open_browser_signin")}</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
class="p-2 text-gray-11 hover:text-gray-12 hover:bg-gray-4 rounded-lg transition-colors"
|
|
onClick={handleClose}
|
|
>
|
|
<X size={20} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div class="px-6 py-5 space-y-5">
|
|
<Show when={isBusy()}>
|
|
<div class="rounded-xl border border-gray-6/60 bg-gray-1/40 px-5 py-6 text-center space-y-4">
|
|
<div class="flex items-center justify-center">
|
|
<Loader2 size={32} class="animate-spin text-gray-11" />
|
|
</div>
|
|
<div class="space-y-2">
|
|
<p class="text-sm font-medium text-gray-12">
|
|
{translate("mcp.auth.waiting_authorization")}
|
|
</p>
|
|
<p class="text-xs text-gray-10">
|
|
{translate("mcp.auth.follow_browser_steps")}
|
|
</p>
|
|
<button
|
|
type="button"
|
|
class="text-xs text-gray-10 underline underline-offset-2 hover:text-gray-11 transition-colors"
|
|
onClick={handleReopenBrowser}
|
|
>
|
|
{translate("mcp.auth.reopen_browser_link")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</Show>
|
|
|
|
<Show when={!isBusy() && isPreparingReload()}>
|
|
<div class="rounded-xl border border-amber-6/60 bg-amber-2/40 px-5 py-6 text-center space-y-4">
|
|
<div class="flex items-center justify-center">
|
|
<Loader2 size={32} class="animate-spin text-amber-11" />
|
|
</div>
|
|
<div class="space-y-2">
|
|
<p class="text-sm font-medium text-gray-12">
|
|
{props.reloadBlocked
|
|
? translate("mcp.auth.waiting_for_conversation_title")
|
|
: translate("mcp.auth.applying_changes_title")}
|
|
</p>
|
|
<p class="text-xs text-gray-10">
|
|
{props.reloadBlocked
|
|
? translate("mcp.auth.waiting_for_conversation_body")
|
|
: translate("mcp.auth.applying_changes_body")}
|
|
</p>
|
|
</div>
|
|
<Show when={props.reloadBlocked && (props.activeSessions?.length ?? 0) > 0}>
|
|
<div class="space-y-2 text-left">
|
|
<For each={props.activeSessions ?? []}>
|
|
{(session) => (
|
|
<div class="flex items-center justify-between gap-3 rounded-lg border border-amber-6/50 bg-amber-1/40 px-3 py-2">
|
|
<span class="text-xs text-gray-11">
|
|
{translate("mcp.auth.waiting_for_session", { session: session.title })}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
class="text-xs text-amber-11 underline underline-offset-2 hover:text-amber-12 transition-colors disabled:no-underline disabled:opacity-60"
|
|
onClick={() => handleForceStopSession(session.id)}
|
|
disabled={forceStopBusySessionID() === session.id}
|
|
>
|
|
{forceStopBusySessionID() === session.id
|
|
? translate("mcp.auth.force_stopping")
|
|
: translate("mcp.auth.force_stop")}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</For>
|
|
</div>
|
|
</Show>
|
|
</div>
|
|
</Show>
|
|
|
|
<Show when={!isBusy() && alreadyConnected()}>
|
|
<div class="bg-green-7/10 border border-green-7/20 rounded-xl p-5 space-y-4">
|
|
<div class="flex items-center gap-3">
|
|
<div class="flex-shrink-0 w-10 h-10 rounded-full bg-green-7/20 flex items-center justify-center">
|
|
<CheckCircle2 size={24} class="text-green-11" />
|
|
</div>
|
|
<div>
|
|
<p class="text-sm font-medium text-gray-12">{translate("mcp.auth.already_connected")}</p>
|
|
<p class="text-xs text-gray-11">
|
|
{translate("mcp.auth.already_connected_description", { server: serverName() })}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<p class="text-xs text-gray-10">
|
|
{translate("mcp.auth.configured_previously")}
|
|
</p>
|
|
</div>
|
|
</Show>
|
|
|
|
<Show when={reloadNotice()}>
|
|
<div class="bg-gray-1/50 border border-gray-6/70 rounded-xl p-4 space-y-3">
|
|
<p class="text-sm text-gray-11">{reloadNotice()}</p>
|
|
|
|
<div class="flex flex-wrap gap-2 pt-1">
|
|
<Show when={props.onReloadEngine}>
|
|
<Button
|
|
variant="secondary"
|
|
onClick={handleReloadAndRetry}
|
|
disabled={props.reloadBlocked}
|
|
title={props.reloadBlocked ? translate("mcp.reload_banner_blocked_hint") : undefined}
|
|
>
|
|
<RefreshCcw size={14} />
|
|
{translate("mcp.auth.reload_engine_retry")}
|
|
</Button>
|
|
</Show>
|
|
<Button variant="ghost" onClick={handleRetry}>
|
|
{translate("mcp.auth.retry_now")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Show>
|
|
|
|
<Show when={error()}>
|
|
<div class="bg-red-7/10 border border-red-7/20 rounded-xl p-4 space-y-3">
|
|
<p class="text-sm text-red-11">{error()}</p>
|
|
|
|
<Show when={needsReload()}>
|
|
<div class="flex flex-wrap gap-2 pt-2">
|
|
<Show when={props.onReloadEngine}>
|
|
<Button
|
|
variant="secondary"
|
|
onClick={handleReloadAndRetry}
|
|
disabled={props.reloadBlocked}
|
|
title={props.reloadBlocked ? translate("mcp.reload_banner_blocked_hint") : undefined}
|
|
>
|
|
<RefreshCcw size={14} />
|
|
{translate("mcp.auth.reload_engine_retry")}
|
|
</Button>
|
|
</Show>
|
|
<Button variant="ghost" onClick={handleRetry}>
|
|
{translate("mcp.auth.retry_now")}
|
|
</Button>
|
|
</div>
|
|
</Show>
|
|
|
|
<Show when={!needsReload()}>
|
|
<div class="pt-2">
|
|
<Button variant="ghost" onClick={handleRetry}>
|
|
{translate("mcp.auth.retry")}
|
|
</Button>
|
|
</div>
|
|
</Show>
|
|
|
|
<Show when={isInvalidRefreshToken()}>
|
|
<div class="pt-2 space-y-2">
|
|
<p class="text-xs text-red-11">{translate("mcp.auth.invalid_refresh_token")}</p>
|
|
<Show when={!props.isRemoteWorkspace}>
|
|
<Show when={isTauriRuntime()}>
|
|
<Button variant="secondary" onClick={handleCliReauth} disabled={cliAuthBusy()}>
|
|
<Show
|
|
when={cliAuthBusy()}
|
|
fallback={translate("mcp.auth.reauth_action")}
|
|
>
|
|
<Loader2 size={14} class="animate-spin" />
|
|
{translate("mcp.auth.reauth_running")}
|
|
</Show>
|
|
</Button>
|
|
</Show>
|
|
<Show when={!isTauriRuntime()}>
|
|
<div class="text-[11px] text-red-10">
|
|
{translate("mcp.auth.reauth_cli_hint", { server: serverName() })}
|
|
</div>
|
|
</Show>
|
|
</Show>
|
|
<Show when={props.isRemoteWorkspace}>
|
|
<div class="text-[11px] text-red-10">
|
|
{translate("mcp.auth.reauth_remote_hint")}
|
|
</div>
|
|
</Show>
|
|
<Show when={cliAuthResult()}>
|
|
<div class="text-[11px] text-red-10">{cliAuthResult()}</div>
|
|
</Show>
|
|
</div>
|
|
</Show>
|
|
</div>
|
|
</Show>
|
|
|
|
<Show when={!isBusy() && authorizationUrl() && props.isRemoteWorkspace && !alreadyConnected()}>
|
|
<div class="rounded-xl border border-gray-6/60 bg-gray-1/40 p-4 space-y-3">
|
|
<div class="text-xs font-medium text-gray-12">
|
|
{translate("mcp.auth.manual_finish_title")}
|
|
</div>
|
|
<div class="text-xs text-gray-10">
|
|
{translate("mcp.auth.manual_finish_hint")}
|
|
</div>
|
|
<div class="rounded-xl border border-gray-6/70 bg-gray-2/40 px-3 py-2 flex items-center gap-3">
|
|
<div class="flex-1 min-w-0">
|
|
<div class="text-[10px] uppercase tracking-wide text-gray-8">{translate("mcp.auth.authorization_link")}</div>
|
|
<div class="text-[11px] text-gray-11 font-mono truncate">
|
|
{authorizationUrl()}
|
|
</div>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
class="text-xs"
|
|
onClick={handleCopyAuthorizationUrl}
|
|
>
|
|
{authUrlCopied() ? translate("mcp.auth.copied") : translate("mcp.auth.copy_link")}
|
|
</Button>
|
|
</div>
|
|
<TextInput
|
|
label={translate("mcp.auth.callback_label")}
|
|
placeholder={translate("mcp.auth.callback_placeholder")}
|
|
value={callbackInput()}
|
|
onInput={(event) => setCallbackInput(event.currentTarget.value)}
|
|
/>
|
|
<div class="text-[11px] text-gray-9">
|
|
{translate("mcp.auth.port_forward_hint")}
|
|
</div>
|
|
<div class="flex justify-end">
|
|
<Button
|
|
variant="secondary"
|
|
onClick={handleManualComplete}
|
|
disabled={manualAuthBusy() || !callbackInput().trim()}
|
|
>
|
|
<Show
|
|
when={manualAuthBusy()}
|
|
fallback={translate("mcp.auth.complete_connection")}
|
|
>
|
|
<Loader2 size={14} class="animate-spin" />
|
|
{translate("mcp.auth.complete_connection")}
|
|
</Show>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Show>
|
|
|
|
<Show when={!isBusy() && !isPreparingReload() && !error() && !reloadNotice() && !alreadyConnected()}>
|
|
<div class="space-y-4">
|
|
<div class="flex items-start gap-3">
|
|
<div class="flex-shrink-0 w-6 h-6 rounded-full bg-gray-4 flex items-center justify-center text-xs font-medium text-gray-11">
|
|
1
|
|
</div>
|
|
<div>
|
|
<p class="text-sm font-medium text-gray-12">{translate("mcp.auth.step1_title")}</p>
|
|
<p class="text-xs text-gray-10 mt-1">
|
|
{translate("mcp.auth.step1_description", { server: serverName() })}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex items-start gap-3">
|
|
<div class="flex-shrink-0 w-6 h-6 rounded-full bg-gray-4 flex items-center justify-center text-xs font-medium text-gray-11">
|
|
2
|
|
</div>
|
|
<div>
|
|
<p class="text-sm font-medium text-gray-12">{translate("mcp.auth.step2_title")}</p>
|
|
<p class="text-xs text-gray-10 mt-1">
|
|
{translate("mcp.auth.step2_description")}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex items-start gap-3">
|
|
<div class="flex-shrink-0 w-6 h-6 rounded-full bg-gray-4 flex items-center justify-center text-xs font-medium text-gray-11">
|
|
3
|
|
</div>
|
|
<div>
|
|
<p class="text-sm font-medium text-gray-12">{translate("mcp.auth.step3_title")}</p>
|
|
<p class="text-xs text-gray-10 mt-1">
|
|
{translate("mcp.auth.step3_description")}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="rounded-xl border border-gray-6/60 bg-gray-1/40 p-4 text-sm text-gray-11">
|
|
<div class="space-y-3">
|
|
<p>{translate("mcp.auth.waiting_authorization")}</p>
|
|
<p class="text-xs text-gray-10">
|
|
{translate("mcp.auth.follow_browser_steps")}
|
|
</p>
|
|
<button
|
|
type="button"
|
|
class="text-xs text-gray-10 underline underline-offset-2 hover:text-gray-11 transition-colors text-left"
|
|
onClick={handleReopenBrowser}
|
|
>
|
|
{translate("mcp.auth.reopen_browser_link")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</Show>
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div class="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-6 bg-gray-2/50">
|
|
<Show when={alreadyConnected()}>
|
|
<Button variant="primary" onClick={handleComplete}>
|
|
<CheckCircle2 size={16} />
|
|
{translate("mcp.auth.done")}
|
|
</Button>
|
|
</Show>
|
|
<Show when={!alreadyConnected()}>
|
|
<Button variant="ghost" onClick={handleClose}>
|
|
{translate("mcp.auth.cancel")}
|
|
</Button>
|
|
<Button variant="secondary" onClick={handleComplete}>
|
|
<CheckCircle2 size={16} />
|
|
{translate("mcp.auth.im_done")}
|
|
</Button>
|
|
</Show>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Show>
|
|
);
|
|
}
|