fix(oauth): stop Zed paste-token crash and add IDE auto-import (#4359)

- Fix Zed Connect crash by gating paste-token UI when provider has no paste-token config
- Add ZedAuthModal with local IDE keyring auto-import, browser OAuth, and manual callback paste
- Add localhost-only GET /api/oauth/zed/auto-import and POST /api/oauth/zed/import routes
This commit is contained in:
Amir Seify
2026-09-26 16:11:58 +07:00
parent 737b1f4d0a
commit 7a436d209c
8 changed files with 907 additions and 19 deletions

View File

@@ -5,7 +5,7 @@ import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import Image from "next/image";
import { getProviderIconSrc, markProviderIconMissing } from "@/shared/utils/providerIcon";
import { Card, Button, Badge, Input, Modal, CardSkeleton, OAuthModal, KiroOAuthWrapper, CursorAuthModal, XiaomiMimoAuthModal, IFlowCookieModal, GitLabAuthModal, Toggle, Select, EditConnectionModal, NoAuthProxyCard, ConfirmModal } from "@/shared/components";
import { Card, Button, Badge, Input, Modal, CardSkeleton, OAuthModal, KiroOAuthWrapper, CursorAuthModal, ZedAuthModal, XiaomiMimoAuthModal, IFlowCookieModal, GitLabAuthModal, Toggle, Select, EditConnectionModal, NoAuthProxyCard, ConfirmModal } from "@/shared/components";
import { OAUTH_PROVIDERS, APIKEY_PROVIDERS, FREE_PROVIDERS, FREE_TIER_PROVIDERS, WEB_COOKIE_PROVIDERS, getProviderAlias, isOpenAICompatibleProvider, isAnthropicCompatibleProvider, AI_PROVIDERS } from "@/shared/constants/providers";
import { getModelsByProviderId, getModelKind } from "@/shared/constants/models";
import { getThinkingLevels } from "open-sse/providers/thinkingLevels.js";
@@ -1836,6 +1836,13 @@ export default function ProviderDetailPage() {
onSuccess={handleOAuthSuccess}
onClose={() => setShowOAuthModal(false)}
/>
) : providerId === "zed" ? (
<ZedAuthModal
isOpen={showOAuthModal}
providerInfo={providerInfo}
onSuccess={handleOAuthSuccess}
onClose={() => setShowOAuthModal(false)}
/>
) : providerId === "gitlab" ? (
<GitLabAuthModal
isOpen={showOAuthModal}

View File

@@ -0,0 +1,39 @@
import { NextResponse } from "next/server";
import { readZedIdeCredentials } from "@/lib/oauth/utils/zedCredentials";
/**
* GET /api/oauth/zed/auto-import
* Read the signed-in Zed IDE session from the OS keyring/keychain.
*
* Linux: secret-tool (url=https://zed.dev, label zed-github-account)
* macOS: Keychain internet password (server=https://zed.dev)
* Windows: Credential Manager target zed:url=https://zed.dev
*
* Also loads system_id from Zed's local kv_store when present.
*/
export async function GET() {
try {
const result = await readZedIdeCredentials();
if (!result.found) {
return NextResponse.json({
found: false,
error: result.error || "Zed IDE credentials not found",
credentialsUrl: result.credentialsUrl || null,
});
}
return NextResponse.json({
found: true,
userId: result.userId,
accessToken: result.accessToken,
systemId: result.systemId,
credentialsUrl: result.credentialsUrl,
});
} catch (error) {
console.log("Zed auto-import error:", error);
return NextResponse.json(
{ found: false, error: error.message || "Failed to read Zed credentials" },
{ status: 500 },
);
}
}

View File

@@ -0,0 +1,80 @@
import { NextResponse } from "next/server";
import { randomUUID } from "crypto";
import { createProviderConnection } from "@/models";
import {
fetchZedAuthenticatedUser,
resolveZedOrganizationId,
} from "open-sse/shared/zedAuth.js";
/**
* POST /api/oauth/zed/import
* Validate + save a Zed session (typically from IDE auto-import).
*
* Body: { accessToken, userId, systemId? }
*/
export async function POST(request) {
try {
const body = await request.json();
const accessToken = typeof body?.accessToken === "string" ? body.accessToken.trim() : "";
const userId = body?.userId != null ? String(body.userId).trim() : "";
const systemId =
(typeof body?.systemId === "string" && body.systemId.trim()) || randomUUID();
if (!accessToken) {
return NextResponse.json({ error: "Access token is required" }, { status: 400 });
}
if (!userId) {
return NextResponse.json({ error: "User id is required" }, { status: 400 });
}
const credentials = {
accessToken,
providerSpecificData: { userId, systemId },
};
let userInfo = null;
try {
userInfo = await fetchZedAuthenticatedUser(credentials);
} catch (err) {
return NextResponse.json(
{ error: err.message || "Zed token validation failed" },
{ status: 401 },
);
}
const organizationId = resolveZedOrganizationId(credentials, userInfo);
const email = userInfo?.email || null;
const displayName =
userInfo?.name || userInfo?.display_name || userInfo?.username || `Zed ${userId}`;
const connection = await createProviderConnection({
provider: "zed",
authType: "oauth",
accessToken,
refreshToken: null,
expiresAt: null,
email,
displayName,
providerSpecificData: {
authMethod: "imported",
userId,
systemId,
organizationId: organizationId || "",
},
testStatus: "active",
});
return NextResponse.json({
success: true,
connection: {
id: connection.id,
provider: connection.provider,
email: connection.email,
displayName: connection.displayName,
},
});
} catch (error) {
console.log("Zed import token error:", error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View File

@@ -45,6 +45,7 @@ const ALWAYS_PROTECTED = [
"/api/version/update",
"/api/oauth/cursor/auto-import",
"/api/oauth/kiro/auto-import",
"/api/oauth/zed/auto-import",
];
// Require auth, but allow through if requireLogin is disabled
@@ -81,6 +82,7 @@ const LOCAL_ONLY_PATHS = [
"/api/tunnel/disable",
"/api/oauth/cursor/auto-import",
"/api/oauth/kiro/auto-import",
"/api/oauth/zed/auto-import",
"/api/auth/reset-password",
"/api/headroom/start",
"/api/headroom/stop",

View File

@@ -0,0 +1,342 @@
import { execFile } from "child_process";
import { promisify } from "util";
import { access, constants, readFile } from "fs/promises";
import { homedir } from "os";
import { join } from "path";
import { randomUUID } from "crypto";
const execFileAsync = promisify(execFile);
export const ZED_DEFAULT_CREDENTIALS_URL = "https://zed.dev";
export const ZED_KEYRING_LABEL = "zed-github-account";
/**
* Resolve the credential URL Zed uses as the keyring/keychain key.
* Defaults to https://zed.dev; honors settings.json credentials_url → server_url.
*/
export async function resolveZedCredentialsUrl() {
const settingsPaths = getZedSettingsPaths();
for (const settingsPath of settingsPaths) {
try {
await access(settingsPath, constants.R_OK);
const raw = await readFile(settingsPath, "utf8");
const settings = JSON.parse(raw);
const url =
(typeof settings?.credentials_url === "string" && settings.credentials_url) ||
(typeof settings?.server_url === "string" && settings.server_url) ||
null;
if (url) return url.replace(/\/+$/, "");
} catch {
// try next path
}
}
return ZED_DEFAULT_CREDENTIALS_URL;
}
function getZedSettingsPaths() {
const home = homedir();
if (process.platform === "darwin") {
return [join(home, "Library/Application Support/Zed/settings.json")];
}
if (process.platform === "win32") {
const local = process.env.LOCALAPPDATA || join(home, "AppData", "Local");
return [join(local, "Zed", "settings.json")];
}
const xdg = process.env.XDG_CONFIG_HOME || join(home, ".config");
return [join(xdg, "zed", "settings.json")];
}
/** Candidate paths for Zed's global kv_store (holds system_id). */
export function getZedGlobalDbPaths() {
const home = homedir();
const channels = ["0-global", "0-stable", "0-preview"];
if (process.platform === "darwin") {
const base = join(home, "Library/Application Support/Zed/db");
return channels.map((c) => join(base, c, "db.sqlite"));
}
if (process.platform === "win32") {
const local = process.env.LOCALAPPDATA || join(home, "AppData", "Local");
const base = join(local, "Zed", "db");
return channels.map((c) => join(base, c, "db.sqlite"));
}
const xdg = process.env.XDG_DATA_HOME || join(home, ".local", "share");
const base = join(xdg, "zed", "db");
return channels.map((c) => join(base, c, "db.sqlite"));
}
/**
* Read system_id from Zed's local kv_store (same id the IDE sends to cloud.zed.dev).
*/
export async function readZedSystemId() {
for (const dbPath of getZedGlobalDbPaths()) {
try {
await access(dbPath, constants.R_OK);
} catch {
continue;
}
const value = await queryKvStore(dbPath, "system_id");
if (value) return String(value).trim();
}
return null;
}
async function queryKvStore(dbPath, key) {
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const Database = require("better-sqlite3");
const db = new Database(dbPath, { readonly: true, fileMustExist: true });
try {
const row = db.prepare("SELECT value FROM kv_store WHERE key = ? LIMIT 1").get(key);
return row?.value || null;
} finally {
db.close();
}
} catch {
// Fall back to sqlite3 CLI when native bindings are unavailable.
}
try {
const { stdout } = await execFileAsync(
"sqlite3",
[dbPath, `SELECT value FROM kv_store WHERE key='${key.replace(/'/g, "''")}' LIMIT 1;`],
{ timeout: 5000, windowsHide: true },
);
const value = String(stdout || "").trim();
return value || null;
} catch {
return null;
}
}
/**
* Read Zed IDE session credentials from the OS secret store.
* @returns {Promise<{ found: boolean, userId?: string, accessToken?: string, systemId?: string, credentialsUrl?: string, error?: string }>}
*/
export async function readZedIdeCredentials() {
const credentialsUrl = await resolveZedCredentialsUrl();
let pair = null;
if (process.platform === "linux") {
pair = await readLinuxCredentials(credentialsUrl);
} else if (process.platform === "darwin") {
pair = await readMacCredentials(credentialsUrl);
} else if (process.platform === "win32") {
pair = await readWindowsCredentials(credentialsUrl);
} else {
return {
found: false,
error: `Zed auto-import is not supported on platform ${process.platform}`,
credentialsUrl,
};
}
if (!pair?.userId || !pair?.accessToken) {
return {
found: false,
error:
pair?.error ||
"Zed IDE session not found in the system keyring. Sign in to Zed, then retry.",
credentialsUrl,
};
}
const systemId = (await readZedSystemId()) || randomUUID();
return {
found: true,
userId: String(pair.userId),
accessToken: String(pair.accessToken),
systemId,
credentialsUrl,
};
}
async function readLinuxCredentials(credentialsUrl) {
// Zed (oo7): attributes url + username, label "zed-github-account".
// Note: secret-tool prints attribute.* lines on stderr and label/secret on stdout.
try {
const { stdout, stderr } = await execFileAsync(
"secret-tool",
["search", "--all", "url", credentialsUrl],
{ timeout: 8000, windowsHide: true },
);
const items = parseSecretToolSearch(`${stderr || ""}\n${stdout || ""}`);
const match =
items.find((item) => item.label === ZED_KEYRING_LABEL && item.username && item.secret) ||
items.find((item) => item.username && item.secret) ||
null;
if (match?.username && match?.secret) {
return { userId: match.username, accessToken: match.secret };
}
} catch (err) {
if (err?.code === "ENOENT") {
return { error: "secret-tool not found (install libsecret / secret-tools)" };
}
// Some secret-tool versions exit non-zero but still print useful output.
const merged = `${err?.stderr || ""}\n${err?.stdout || ""}`;
if (merged.includes("secret =")) {
const items = parseSecretToolSearch(merged);
const match = items.find((item) => item.username && item.secret);
if (match) return { userId: match.username, accessToken: match.secret };
}
}
try {
const lookup = await execFileAsync(
"secret-tool",
["lookup", "url", credentialsUrl],
{ timeout: 8000, windowsHide: true },
);
const accessToken = String(lookup.stdout || "").trim();
if (!accessToken) return { error: "Empty Zed keyring secret" };
const search = await execFileAsync(
"secret-tool",
["search", "--all", "url", credentialsUrl],
{ timeout: 8000, windowsHide: true },
);
const items = parseSecretToolSearch(`${search.stderr || ""}\n${search.stdout || ""}`);
const userId = items.find((i) => i.username)?.username;
if (!userId) return { error: "Zed keyring entry missing username (user id)" };
return { userId, accessToken };
} catch (err) {
if (err?.code === "ENOENT") {
return { error: "secret-tool not found (install libsecret / secret-tools)" };
}
return { error: err?.stderr || err?.message || "Failed to read Linux keyring" };
}
}
function parseSecretToolSearch(stdout) {
// Attributes are often on stderr; callers should concatenate stderr+stdout.
// Format:
// attribute.url = https://zed.dev
// attribute.username = 123
// [/60]
// label = zed-github-account
// secret = {...}
const text = String(stdout || "");
const username = text.match(/attribute\.username\s*=\s*(\S+)/)?.[1] || null;
const label = text.match(/^\s*label\s*=\s*(.+)$/m)?.[1]?.trim() || null;
const secretLine = text.match(/^\s*secret\s*=\s*(.*)$/m)?.[1];
const secret = secretLine != null ? secretLine.trim() : null;
if (!username && !secret && !label) return [];
return [{ label, username, secret }];
}
async function readMacCredentials(credentialsUrl) {
// Zed stores an internet password with kSecAttrServer = full credentials URL.
const servers = unique([credentialsUrl, stripUrlScheme(credentialsUrl), "zed.dev"]);
let lastError = null;
for (const server of servers) {
try {
// -g prints password to stderr as "password: \"...\""
const { stdout, stderr } = await execFileAsync(
"security",
["find-internet-password", "-s", server, "-g"],
{ timeout: 8000, windowsHide: true },
);
const combined = `${stdout || ""}\n${stderr || ""}`;
const password = parseSecurityPassword(combined);
const account =
combined.match(/"acct"<blob>="([^"]*)"/)?.[1] ||
combined.match(/"acct"<blob>=0x[0-9A-Fa-f]+\s+"([^"]*)"/)?.[1] ||
null;
if (password && account) {
return { userId: account, accessToken: password };
}
if (password && !account) {
lastError = "Found Zed keychain password but missing account (user id)";
}
} catch (err) {
lastError = err?.stderr || err?.message || lastError;
}
}
return { error: lastError || "Zed credentials not found in macOS Keychain" };
}
function parseSecurityPassword(text) {
const m = String(text).match(/password:\s*"(.*)"\s*$/m);
if (m) return unescapeSecurityPassword(m[1]);
// Empty password prints as "password: "
if (/password:\s*$/m.test(text)) return "";
return null;
}
function unescapeSecurityPassword(value) {
// security escapes \ and " in the quoted password dump
return value.replace(/\\(.)/g, "$1");
}
async function readWindowsCredentials(credentialsUrl) {
// Zed target name: zed:url=https://zed.dev
const target = `zed:url=${credentialsUrl}`;
const script = `
$ErrorActionPreference = 'Stop'
Add-Type -TypeDefinition @"
using System;
using System.Runtime.InteropServices;
using System.Text;
public class ZedCred {
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
public struct CREDENTIAL {
public uint Flags; public uint Type; public string TargetName; public string Comment;
public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten;
public uint CredentialBlobSize; public IntPtr CredentialBlob; public uint Persist;
public uint AttributeCount; public IntPtr Attributes; public string TargetAlias; public string UserName;
}
[DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
public static extern bool CredRead(string target, uint type, uint reservedFlag, out IntPtr credentialPtr);
[DllImport("advapi32.dll", SetLastError = true)]
public static extern void CredFree(IntPtr buffer);
public static string Read(string target) {
IntPtr p;
if (!CredRead(target, 1, 0, out p)) return null;
try {
var c = (CREDENTIAL)Marshal.PtrToStructure(p, typeof(CREDENTIAL));
string secret = "";
if (c.CredentialBlob != IntPtr.Zero && c.CredentialBlobSize > 0) {
byte[] bytes = new byte[c.CredentialBlobSize];
Marshal.Copy(c.CredentialBlob, bytes, 0, (int)c.CredentialBlobSize);
secret = Encoding.UTF8.GetString(bytes);
}
return (c.UserName ?? "") + "\\n" + secret;
} finally { CredFree(p); }
}
}
"@
$r = [ZedCred]::Read(${JSON.stringify(target)})
if ($null -eq $r) { exit 2 }
Write-Output $r
`.trim();
try {
const { stdout } = await execFileAsync(
"powershell.exe",
["-NoProfile", "-NonInteractive", "-Command", script],
{ timeout: 15000, windowsHide: true, maxBuffer: 2 * 1024 * 1024 },
);
const text = String(stdout || "").replace(/^\uFEFF/, "");
const nl = text.indexOf("\n");
if (nl < 0) return { error: "Malformed Windows credential payload" };
const userId = text.slice(0, nl).trim();
const accessToken = text.slice(nl + 1).replace(/\r?\n$/, "");
if (!userId || !accessToken) {
return { error: "Windows Credential Manager entry missing username or secret" };
}
return { userId, accessToken };
} catch (err) {
if (err?.code === 2 || err?.status === 2) {
return { error: "Zed credentials not found in Windows Credential Manager" };
}
return { error: err?.stderr || err?.message || "Failed to read Windows credentials" };
}
}
function stripUrlScheme(url) {
return String(url || "").replace(/^https?:\/\//i, "").replace(/\/+$/, "");
}
function unique(arr) {
return [...new Set(arr.filter(Boolean))];
}

View File

@@ -753,25 +753,27 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
return (
<Modal isOpen={isOpen} title={modalTitle} onClose={handleClose} size="lg">
<div className="flex flex-col gap-4">
{/* Trae/Windsurf: browser OAuth (proxy) + paste-token fallback */}
{/* Proxy OAuth (trae/windsurf/zed): browser flow; paste-token only when configured */}
{PROXY_OAUTH_PROVIDERS.has(provider) && (step === "waiting" || step === "input" || step === "error") && (
<>
<div className="flex gap-2">
<button
type="button"
onClick={() => { setAuthMode("browser"); setError(null); setStep("waiting"); startOAuthFlow(); }}
className={`flex-1 rounded-lg border px-3 py-2 text-sm transition-colors ${authMode === "browser" ? "border-primary bg-primary/10 text-primary" : "border-border text-text-muted hover:text-primary"}`}
>
🌐 Sign in with browser
</button>
<button
type="button"
onClick={() => { setAuthMode("paste-token"); setError(null); setStep("input"); }}
className={`flex-1 rounded-lg border px-3 py-2 text-sm transition-colors ${authMode === "paste-token" ? "border-primary bg-primary/10 text-primary" : "border-border text-text-muted hover:text-primary"}`}
>
🔑 Paste token
</button>
</div>
{PASTE_TOKEN_PROVIDERS[provider] && (
<div className="flex gap-2">
<button
type="button"
onClick={() => { setAuthMode("browser"); setError(null); setStep("waiting"); startOAuthFlow(); }}
className={`flex-1 rounded-lg border px-3 py-2 text-sm transition-colors ${authMode === "browser" ? "border-primary bg-primary/10 text-primary" : "border-border text-text-muted hover:text-primary"}`}
>
🌐 Sign in with browser
</button>
<button
type="button"
onClick={() => { setAuthMode("paste-token"); setError(null); setStep("input"); }}
className={`flex-1 rounded-lg border px-3 py-2 text-sm transition-colors ${authMode === "paste-token" ? "border-primary bg-primary/10 text-primary" : "border-border text-text-muted hover:text-primary"}`}
>
🔑 Paste token
</button>
</div>
)}
{authMode === "browser" && (
<>
@@ -801,7 +803,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
</>
)}
{authMode === "paste-token" && (
{authMode === "paste-token" && PASTE_TOKEN_PROVIDERS[provider] && (
<div className="space-y-3">
{ideStatus && !ideStatus.installed && (
<div className={`px-3 py-2 rounded-lg text-sm ${PASTE_TOKEN_PROVIDERS[provider].ideOptional ? "bg-blue-500/10 text-blue-700 dark:text-blue-300" : "bg-yellow-500/10 text-yellow-700 dark:text-yellow-300"}`}>

View File

@@ -0,0 +1,415 @@
"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";
/**
* Zed Connect modal (Codex-style):
* 1. Auto-import from Zed IDE keyring when a session is detected
* 2. Browser OAuth (local proxy) with waiting spinner
* 3. Manual paste of the callback URL
*/
export default function ZedAuthModal({ isOpen, providerInfo, onSuccess, onClose }) {
const [phase, setPhase] = useState("booting"); // booting | ide-found | browser | importing | success | error
const [ideSession, setIdeSession] = useState(null);
const [authData, setAuthData] = useState(null);
const [callbackUrl, setCallbackUrl] = useState("");
const [error, setError] = useState(null);
const [busy, setBusy] = useState(false);
const popupRef = useRef(null);
const flowRef = useRef({ proxyStarted: false, stopSent: false });
const openedRef = useRef(false);
const pollAbortRef = useRef(false);
const isOpenRef = useRef(isOpen);
const onSuccessRef = useRef(onSuccess);
const onCloseRef = useRef(onClose);
const { copied, copy } = useCopyToClipboard();
useEffect(() => {
isOpenRef.current = isOpen;
onSuccessRef.current = onSuccess;
onCloseRef.current = onClose;
});
const stopOwnedProxy = useCallback(() => {
const flow = flowRef.current;
if (flow.proxyStarted && !flow.stopSent) {
flow.stopSent = true;
fetch("/api/oauth/zed/stop-proxy").catch(() => {});
}
}, []);
const finishSuccess = useCallback(() => {
setPhase("success");
onSuccessRef.current?.();
setTimeout(() => onCloseRef.current?.(), 600);
}, []);
const importIdeSession = useCallback(async (session) => {
if (!session?.accessToken || !session?.userId) return;
setBusy(true);
setError(null);
setPhase("importing");
try {
const res = await fetch("/api/oauth/zed/import", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
accessToken: session.accessToken,
userId: session.userId,
...(session.systemId ? { systemId: session.systemId } : {}),
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Import failed");
stopOwnedProxy();
finishSuccess();
} catch (err) {
setError(err.message);
setPhase("ide-found");
} finally {
setBusy(false);
}
}, [finishSuccess, stopOwnedProxy]);
const startBrowserFlow = useCallback(async () => {
setError(null);
setAuthData(null);
setCallbackUrl("");
flowRef.current = { proxyStarted: false, stopSent: false };
pollAbortRef.current = false;
try {
const startRes = await fetch("/api/oauth/zed/start-proxy");
const startData = await startRes.json();
if (!startRes.ok || !startData.success || !startData.callbackUrl) {
throw new Error(startData.reason || startData.error || "Failed to start Zed callback server");
}
flowRef.current.proxyStarted = true;
flowRef.current.stopSent = false;
if (!isOpenRef.current) {
stopOwnedProxy();
return;
}
const authorizeUrl = new URL("/api/oauth/zed/authorize", window.location.origin);
authorizeUrl.searchParams.set("redirect_uri", startData.callbackUrl);
const authRes = await fetch(authorizeUrl);
const nextAuth = await authRes.json();
if (!authRes.ok) {
stopOwnedProxy();
throw new Error(nextAuth.error || "Failed to start Zed authorization");
}
if (!isOpenRef.current) {
stopOwnedProxy();
return;
}
const regBody = { state: nextAuth.state };
if (nextAuth.codeVerifier) regBody.codeVerifier = nextAuth.codeVerifier;
if (nextAuth.systemId) regBody.systemId = nextAuth.systemId;
const regRes = await fetch("/api/oauth/zed/register-session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(regBody),
});
let regData = null;
try {
regData = await regRes.json();
} catch {
regData = null;
}
if (!regRes.ok || regData?.success === false) {
stopOwnedProxy();
throw new Error(regData?.error || "Failed to register Zed login session");
}
if (!isOpenRef.current) return;
setAuthData(nextAuth);
setPhase((prev) => (prev === "ide-found" || prev === "importing" ? prev : "browser"));
popupRef.current = window.open(nextAuth.authUrl, "oauth_popup_zed", "width=600,height=700");
} catch (err) {
if (!isOpenRef.current) return;
setError(err.message);
setPhase((prev) => (prev === "ide-found" ? prev : "browser"));
}
}, [stopOwnedProxy]);
// Open: detect IDE session (auto-import if found), always start browser flow as fallback.
useEffect(() => {
if (!isOpen) return;
if (openedRef.current) return;
openedRef.current = true;
setPhase("booting");
setIdeSession(null);
setAuthData(null);
setCallbackUrl("");
setError(null);
setBusy(false);
pollAbortRef.current = false;
flowRef.current = { proxyStarted: false, stopSent: false };
let cancelled = false;
(async () => {
// Browser flow runs in parallel so paste-callback is always available.
const browserPromise = startBrowserFlow();
try {
const res = await fetch("/api/oauth/zed/auto-import", {
signal: AbortSignal.timeout(12000),
});
const data = await res.json();
if (cancelled || !isOpenRef.current) return;
if (data.found && data.accessToken && data.userId) {
const session = {
accessToken: data.accessToken,
userId: String(data.userId),
systemId: data.systemId || "",
};
setIdeSession(session);
// Auto-import when Zed IDE session is detected.
await importIdeSession(session);
return;
}
} catch {
// Fall through to browser UI
}
if (cancelled || !isOpenRef.current) return;
await browserPromise;
if (!cancelled && isOpenRef.current) setPhase("browser");
})();
return () => {
cancelled = true;
};
}, [isOpen, startBrowserFlow, importIdeSession]);
// Cleanup on close
useEffect(() => {
if (isOpen) return;
openedRef.current = false;
pollAbortRef.current = true;
stopOwnedProxy();
flowRef.current = { proxyStarted: false, stopSent: false };
if (popupRef.current && !popupRef.current.closed) {
try {
popupRef.current.close();
} catch {
// ignore
}
}
}, [isOpen, stopOwnedProxy]);
// Poll proxy until browser OAuth completes
useEffect(() => {
if (!authData?.state) return;
if (phase === "importing" || phase === "success") return;
let cancelled = false;
let attempts = 0;
const MAX_ATTEMPTS = 200;
const tick = async () => {
if (cancelled || pollAbortRef.current || !isOpenRef.current) return;
attempts += 1;
try {
const res = await fetch(
`/api/oauth/zed/poll-status?state=${encodeURIComponent(authData.state)}`,
);
const data = await res.json();
if (cancelled || pollAbortRef.current) return;
if (data.status === "done") {
pollAbortRef.current = true;
stopOwnedProxy();
finishSuccess();
return;
}
if (data.status === "error") {
pollAbortRef.current = true;
setError(data.error || "Authentication failed");
setPhase("error");
return;
}
} catch {
// keep polling
}
if (attempts >= MAX_ATTEMPTS) {
setError("Authentication timeout");
setPhase("error");
return;
}
setTimeout(tick, 1500);
};
setTimeout(tick, 1500);
return () => {
cancelled = true;
};
}, [authData, phase, finishSuccess, stopOwnedProxy]);
const handleManualCallback = async () => {
const input = callbackUrl.trim();
if (!input) return;
setBusy(true);
setError(null);
try {
const res = await fetch("/api/oauth/zed/exchange", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
code: input,
state: authData?.state,
...(authData?.redirectUri ? { redirectUri: authData.redirectUri } : {}),
...(authData?.codeVerifier ? { codeVerifier: authData.codeVerifier } : {}),
...(authData?.systemId ? { systemId: authData.systemId } : {}),
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Exchange failed");
pollAbortRef.current = true;
stopOwnedProxy();
finishSuccess();
} catch (err) {
setError(err.message);
setPhase("error");
} finally {
setBusy(false);
}
};
const handleClose = () => {
pollAbortRef.current = true;
stopOwnedProxy();
onClose();
};
const title = `Connect ${providerInfo?.name || "Zed"}`;
const showBrowserUi = phase === "browser" || phase === "error" || phase === "ide-found";
return (
<Modal isOpen={isOpen} title={title} onClose={handleClose} size="lg">
<div className="flex flex-col gap-4">
{(phase === "booting" || phase === "importing") && (
<div className="flex items-center gap-2 px-3 py-2 border border-border rounded-lg bg-sidebar/50">
<span className="material-symbols-outlined text-base text-primary animate-spin">
progress_activity
</span>
<span className="text-sm">
{phase === "importing"
? "Importing session from Zed IDE…"
: "Detecting Zed IDE session…"}
</span>
</div>
)}
{phase === "ide-found" && ideSession && (
<div className="space-y-3">
<div className="bg-green-50 dark:bg-green-900/20 p-3 rounded-lg border border-green-200 dark:border-green-800">
<div className="flex gap-2">
<span className="material-symbols-outlined text-green-600 dark:text-green-400">
check_circle
</span>
<p className="text-sm text-green-800 dark:text-green-200">
Zed IDE session detected (user {ideSession.userId}). Import failed — retry or use browser sign-in below.
</p>
</div>
</div>
<Button onClick={() => importIdeSession(ideSession)} fullWidth disabled={busy}>
{busy ? "Importing…" : "Import from Zed IDE"}
</Button>
</div>
)}
{phase === "success" && (
<div className="bg-green-50 dark:bg-green-900/20 p-3 rounded-lg border border-green-200 dark:border-green-800 text-sm text-green-800 dark:text-green-200">
Connected successfully.
</div>
)}
{showBrowserUi && (
<>
<div className="flex items-center gap-2 px-3 py-2 border border-border rounded-lg bg-sidebar/50">
<span className="material-symbols-outlined text-base text-primary animate-spin">
progress_activity
</span>
<span className="text-sm">Waiting for popup authorization…</span>
</div>
<div className="flex items-center gap-3 my-1">
<div className="flex-1 h-px bg-border" />
<span className="text-xs text-text-muted uppercase tracking-wider">
Or paste callback URL manually
</span>
<div className="flex-1 h-px bg-border" />
</div>
<div className="space-y-4">
<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")}
disabled={!authData?.authUrl}
>
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 (or the local callback page).
</p>
<Input
value={callbackUrl}
onChange={(e) => setCallbackUrl(e.target.value)}
placeholder="http://127.0.0.1:.../?user_id=...&access_token=..."
className="font-mono text-xs"
/>
</div>
</div>
{error && (
<div className="bg-red-50 dark:bg-red-900/20 p-3 rounded-lg border border-red-200 dark:border-red-800">
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
</div>
)}
<div className="flex gap-2">
<Button
onClick={handleManualCallback}
fullWidth
disabled={busy || !callbackUrl.trim() || !authData}
>
{busy ? "Connecting…" : "Connect"}
</Button>
<Button onClick={handleClose} variant="ghost" fullWidth>
Cancel
</Button>
</div>
</>
)}
</div>
</Modal>
);
}
ZedAuthModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
providerInfo: PropTypes.object,
onSuccess: PropTypes.func,
onClose: PropTypes.func.isRequired,
};

View File

@@ -27,6 +27,7 @@ export { default as KiroAuthModal } from "./KiroAuthModal";
export { default as KiroOAuthWrapper } from "./KiroOAuthWrapper";
export { default as KiroSocialOAuthModal } from "./KiroSocialOAuthModal";
export { default as CursorAuthModal } from "./CursorAuthModal";
export { default as ZedAuthModal } from "./ZedAuthModal";
export { default as XiaomiMimoAuthModal } from "./XiaomiMimoAuthModal";
export { default as IFlowCookieModal } from "./IFlowCookieModal";
export { default as GitLabAuthModal } from "./GitLabAuthModal";