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:
@@ -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}
|
||||
|
||||
39
src/app/api/oauth/zed/auto-import/route.js
Normal file
39
src/app/api/oauth/zed/auto-import/route.js
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
80
src/app/api/oauth/zed/import/route.js
Normal file
80
src/app/api/oauth/zed/import/route.js
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
342
src/lib/oauth/utils/zedCredentials.js
Normal file
342
src/lib/oauth/utils/zedCredentials.js
Normal 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))];
|
||||
}
|
||||
@@ -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"}`}>
|
||||
|
||||
415
src/shared/components/ZedAuthModal.js
Normal file
415
src/shared/components/ZedAuthModal.js
Normal 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,
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user