diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.js b/src/app/(dashboard)/dashboard/providers/[id]/page.js index f3a5360a..61b416ef 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.js @@ -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" ? ( + setShowOAuthModal(false)} + /> ) : providerId === "gitlab" ? ( 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"="([^"]*)"/)?.[1] || + combined.match(/"acct"=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))]; +} diff --git a/src/shared/components/OAuthModal.js b/src/shared/components/OAuthModal.js index b8f67a06..7a76a020 100644 --- a/src/shared/components/OAuthModal.js +++ b/src/shared/components/OAuthModal.js @@ -753,25 +753,27 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, return (
- {/* 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") && ( <> -
- - -
+ {PASTE_TOKEN_PROVIDERS[provider] && ( +
+ + +
+ )} {authMode === "browser" && ( <> @@ -801,7 +803,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, )} - {authMode === "paste-token" && ( + {authMode === "paste-token" && PASTE_TOKEN_PROVIDERS[provider] && (
{ideStatus && !ideStatus.installed && (
diff --git a/src/shared/components/ZedAuthModal.js b/src/shared/components/ZedAuthModal.js new file mode 100644 index 00000000..2d1cfecb --- /dev/null +++ b/src/shared/components/ZedAuthModal.js @@ -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 ( + +
+ {(phase === "booting" || phase === "importing") && ( +
+ + progress_activity + + + {phase === "importing" + ? "Importing session from Zed IDE…" + : "Detecting Zed IDE session…"} + +
+ )} + + {phase === "ide-found" && ideSession && ( +
+
+
+ + check_circle + +

+ Zed IDE session detected (user {ideSession.userId}). Import failed — retry or use browser sign-in below. +

+
+
+ +
+ )} + + {phase === "success" && ( +
+ Connected successfully. +
+ )} + + {showBrowserUi && ( + <> +
+ + progress_activity + + Waiting for popup authorization… +
+ +
+
+ + Or paste callback URL manually + +
+
+ +
+
+

Step 1: Open this URL in your browser

+
+ + +
+
+ +
+

Step 2: Paste the callback URL here

+

+ After authorization, copy the full URL from your browser (or the local callback page). +

+ setCallbackUrl(e.target.value)} + placeholder="http://127.0.0.1:.../?user_id=...&access_token=..." + className="font-mono text-xs" + /> +
+
+ + {error && ( +
+

{error}

+
+ )} + +
+ + +
+ + )} +
+ + ); +} + +ZedAuthModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + providerInfo: PropTypes.object, + onSuccess: PropTypes.func, + onClose: PropTypes.func.isRequired, +}; diff --git a/src/shared/components/index.js b/src/shared/components/index.js index e5698a63..b51e27e1 100644 --- a/src/shared/components/index.js +++ b/src/shared/components/index.js @@ -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";