mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-23 15:42:12 +03:00
feat(providers): Cursor PKCE login with Bearer quota, auto router, and empty-turn errors (#9909)
⭐5 — Cursor PKCE login com Bearer quota, auto router e empty-turn errors. Feature completa e testada (11 arquivos de teste, 133 testes focados, todos verdes).
**Validação (worktree combinado `.claude/worktrees/fix-9909`, board sobre `origin/release/v3.8.50`):**
- 3 conflitos reais resolvidos: `config/quality/eslint-suppressions.json` (aditivo), `open-sse/config/providers/registry/cursor/index.ts` (dedup de 208 entradas de catálogo, 0 IDs duplicados verificado), `open-sse/executors/cursor.ts` (imports aditivos).
- `npm run typecheck:core`: limpo.
- `check-changelog-integrity`, `check-file-size`, `check-complexity` (2615/2774), `check-cognitive-complexity` (1175/1223), `check-dead-code` (410/416): todos OK.
- `check-public-creds`: 1 entrada obsoleta pré-existente na allowlist (`copilot-m365-web.ts:330`), já presente no tip da release — não é desta PR.
- `npm run lint`: 0 errors (5 warnings pré-existentes).
- Testes focados (`cursor-agent-cli-version`, `cursor-available-models`, `cursor-catalog-combo-compat`, `cursor-errors-classify`, `cursor-login-pkce`, `cursor-model-effort-suffix-7289`, `cursor-streaming`, `cursor-token-extractor`, `cursor-token-refresh-wiring`, `cursor-usage-fetcher`, `empty-stream-no-content-8649`): 133/133 verdes.
- Corrigido durante a validação: 1 teste novo da própria PR (`cursor-model-effort-suffix-7289.test.ts`, "splits effort off legacy grok- ids") colidia com `CURSOR_MODEL_ALIASES` já mesclado na release (mapeia `grok-4.5-high` → `cursor-grok-4.5-high` antes do fallback legado rodar); ajustado para usar um id não-aliasado (`grok-3-high`) que de fato exercita o fallback — commit `68b58ed`.
Obrigado pela contribuição, @yansigit — feature robusta com boa cobertura de testes.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Modal from "./Modal";
|
||||
import Button from "./Button";
|
||||
@@ -13,9 +13,12 @@ type CursorAuthModalProps = {
|
||||
reauthConnection?: unknown;
|
||||
};
|
||||
|
||||
type AuthTab = "login" | "import";
|
||||
|
||||
/**
|
||||
* Cursor Auth Modal
|
||||
* Auto-detect and import token from Cursor IDE's local SQLite database
|
||||
* Primary: deep-control PKCE login (Docker-friendly).
|
||||
* Secondary: IDE / paste-token import (optional refresh token).
|
||||
*/
|
||||
export default function CursorAuthModal({
|
||||
isOpen,
|
||||
@@ -24,43 +27,161 @@ export default function CursorAuthModal({
|
||||
reauthConnection: _,
|
||||
}: CursorAuthModalProps) {
|
||||
const t = useTranslations("cursorAuthModal");
|
||||
const [tab, setTab] = useState<AuthTab>("login");
|
||||
const [accessToken, setAccessToken] = useState("");
|
||||
const [refreshToken, setRefreshToken] = useState("");
|
||||
const [machineId, setMachineId] = useState("");
|
||||
const [error, setError] = useState(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [autoDetecting, setAutoDetecting] = useState(false);
|
||||
const [autoDetected, setAutoDetected] = useState(false);
|
||||
const [dockerHint, setDockerHint] = useState(false);
|
||||
|
||||
const [loginUrl, setLoginUrl] = useState("");
|
||||
const [sessionId, setSessionId] = useState("");
|
||||
const [loginStarting, setLoginStarting] = useState(false);
|
||||
const [loginPolling, setLoginPolling] = useState(false);
|
||||
const pollAbortRef = useRef(false);
|
||||
|
||||
// Auto-detect tokens when modal opens
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
if (!isOpen) {
|
||||
pollAbortRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
pollAbortRef.current = false;
|
||||
|
||||
// Reset modal UI state for this open. Nested in a function (like autoDetect
|
||||
// below) rather than called directly in the effect body, since these are a
|
||||
// response to the modal being (re)opened — not a synchronization of React
|
||||
// state with an external system — and react-hooks/set-state-in-effect flags
|
||||
// direct top-level setState calls in an effect.
|
||||
const resetModalState = () => {
|
||||
setTab("login");
|
||||
setError(null);
|
||||
setLoginUrl("");
|
||||
setSessionId("");
|
||||
setLoginPolling(false);
|
||||
// Detect Docker-ish environments for import-tab guidance
|
||||
setDockerHint(false);
|
||||
};
|
||||
resetModalState();
|
||||
|
||||
const autoDetect = async () => {
|
||||
setAutoDetecting(true);
|
||||
setError(null);
|
||||
setAutoDetected(false);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/oauth/cursor/auto-import");
|
||||
const data = await res.json();
|
||||
|
||||
if (data.found) {
|
||||
setAccessToken(data.accessToken);
|
||||
setAccessToken(data.accessToken || "");
|
||||
setRefreshToken(data.refreshToken || "");
|
||||
setMachineId(data.machineId || "");
|
||||
setAutoDetected(true);
|
||||
} else {
|
||||
setError(data.error || t("errorAutoDetect"));
|
||||
// Soft hint only on import tab; don't block login tab
|
||||
if (
|
||||
typeof data.error === "string" &&
|
||||
/docker|config files found|not appear/i.test(data.error)
|
||||
) {
|
||||
setDockerHint(true);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError(t("errorAutoDetectFailed"));
|
||||
} catch {
|
||||
/* ignore — login tab is primary */
|
||||
} finally {
|
||||
setAutoDetecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
autoDetect();
|
||||
void autoDetect();
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
pollAbortRef.current = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleStartLogin = async () => {
|
||||
setLoginStarting(true);
|
||||
setError(null);
|
||||
setLoginUrl("");
|
||||
setSessionId("");
|
||||
try {
|
||||
const res = await fetch("/api/oauth/cursor/login/start", { method: "POST" });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || t("errorLoginStart"));
|
||||
setLoginUrl(data.loginUrl);
|
||||
setSessionId(data.sessionId);
|
||||
if (typeof window !== "undefined" && data.loginUrl) {
|
||||
window.open(data.loginUrl, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
void pollUntilDone(data.sessionId);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("errorLoginStart"));
|
||||
} finally {
|
||||
setLoginStarting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const pollUntilDone = async (sid: string) => {
|
||||
setLoginPolling(true);
|
||||
pollAbortRef.current = false;
|
||||
const maxAttempts = 150;
|
||||
let delayMs = 1000;
|
||||
try {
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
if (pollAbortRef.current) return;
|
||||
await new Promise((r) => setTimeout(r, delayMs));
|
||||
if (pollAbortRef.current) return;
|
||||
|
||||
const res = await fetch("/api/oauth/cursor/login/poll", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sessionId: sid }),
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.status === "pending") {
|
||||
delayMs = Math.min(delayMs * 1.2, 10_000);
|
||||
continue;
|
||||
}
|
||||
if (data.status === "ok" || data.success) {
|
||||
onSuccess?.();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
throw new Error(data.error || t("errorLoginPoll"));
|
||||
}
|
||||
throw new Error(t("errorLoginTimeout"));
|
||||
} catch (err) {
|
||||
if (!pollAbortRef.current) {
|
||||
setError(err instanceof Error ? err.message : t("errorLoginPoll"));
|
||||
}
|
||||
} finally {
|
||||
setLoginPolling(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelLogin = async () => {
|
||||
pollAbortRef.current = true;
|
||||
if (sessionId) {
|
||||
try {
|
||||
await fetch("/api/oauth/cursor/login/cancel", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sessionId }),
|
||||
});
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
setLoginPolling(false);
|
||||
setLoginUrl("");
|
||||
setSessionId("");
|
||||
};
|
||||
|
||||
const handleImportToken = async () => {
|
||||
if (!accessToken.trim()) {
|
||||
setError(t("errorEnterToken"));
|
||||
@@ -73,6 +194,7 @@ export default function CursorAuthModal({
|
||||
try {
|
||||
const body: Record<string, string> = { accessToken: accessToken.trim() };
|
||||
if (machineId.trim()) body.machineId = machineId.trim();
|
||||
if (refreshToken.trim()) body.refreshToken = refreshToken.trim();
|
||||
|
||||
const res = await fetch("/api/oauth/cursor/import", {
|
||||
method: "POST",
|
||||
@@ -83,67 +205,122 @@ export default function CursorAuthModal({
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || t("errorImportFailed"));
|
||||
throw new Error(
|
||||
typeof data.error === "string"
|
||||
? data.error
|
||||
: data.error?.message || t("errorImportFailed")
|
||||
);
|
||||
}
|
||||
|
||||
// Success - close modal and trigger refresh
|
||||
onSuccess?.();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
setError(err instanceof Error ? err.message : t("errorImportFailed"));
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
void handleCancelLogin();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} title={t("title")} onClose={onClose}>
|
||||
<Modal isOpen={isOpen} title={t("title")} onClose={handleClose}>
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Auto-detecting state */}
|
||||
{autoDetecting && (
|
||||
<div className="text-center py-6">
|
||||
<div className="size-16 mx-auto mb-4 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<span className="material-symbols-outlined text-3xl text-primary animate-spin">
|
||||
progress_activity
|
||||
</span>
|
||||
<div className="flex gap-2 border-b border-border pb-2">
|
||||
<Button
|
||||
variant={tab === "login" ? "primary" : "ghost"}
|
||||
onClick={() => setTab("login")}
|
||||
disabled={loginPolling}
|
||||
>
|
||||
{t("tabLogin")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={tab === "import" ? "primary" : "ghost"}
|
||||
onClick={() => setTab("import")}
|
||||
disabled={loginPolling}
|
||||
>
|
||||
{t("tabImport")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{tab === "login" && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm text-text-muted">{t("loginDescription")}</p>
|
||||
|
||||
{loginPolling && (
|
||||
<div className="text-center py-4">
|
||||
<div className="size-12 mx-auto mb-3 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<span className="material-symbols-outlined text-2xl text-primary animate-spin">
|
||||
progress_activity
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm font-medium">{t("waitingApproval")}</p>
|
||||
{loginUrl && (
|
||||
<p className="text-xs text-text-muted mt-2 break-all">
|
||||
{t("openUrlHint")}{" "}
|
||||
<a
|
||||
href={loginUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline"
|
||||
>
|
||||
{t("openLoginLink")}
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
</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">
|
||||
{!loginPolling ? (
|
||||
<Button onClick={handleStartLogin} fullWidth disabled={loginStarting}>
|
||||
{loginStarting ? t("startingLogin") : t("loginWithCursor")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={handleCancelLogin} variant="ghost" fullWidth>
|
||||
{t("cancelLogin")}
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={handleClose} variant="ghost" fullWidth disabled={loginPolling}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">{t("autoDetecting")}</h3>
|
||||
<p className="text-sm text-text-muted">{t("readingFromCursor")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form (shown after auto-detect completes) */}
|
||||
{!autoDetecting && (
|
||||
<>
|
||||
{/* Success message if auto-detected */}
|
||||
{autoDetected && (
|
||||
{tab === "import" && (
|
||||
<div className="flex flex-col gap-4">
|
||||
{autoDetecting && (
|
||||
<div className="text-center py-4">
|
||||
<p className="text-sm text-text-muted">{t("autoDetecting")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!autoDetecting && autoDetected && (
|
||||
<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">
|
||||
{t("tokensAutoDetected")}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm text-green-800 dark:text-green-200">
|
||||
{t("tokensAutoDetected")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info message if not auto-detected */}
|
||||
{!autoDetected && !error && (
|
||||
{!autoDetecting && !autoDetected && (
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 p-3 rounded-lg border border-blue-200 dark:border-blue-800">
|
||||
<div className="flex gap-2">
|
||||
<span className="material-symbols-outlined text-blue-600 dark:text-blue-400">
|
||||
info
|
||||
</span>
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||
{t("cursorNotDetected")}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||
{dockerHint ? t("dockerImportHint") : t("cursorNotDetected")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Access Token Input */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
{t("accessToken")} <span className="text-red-500">{t("required")}</span>
|
||||
@@ -157,7 +334,19 @@ export default function CursorAuthModal({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Machine ID Input (optional — not needed for cursor-agent imports) */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
{t("refreshToken")} <span className="text-text-muted text-xs">{t("optional")}</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={refreshToken}
|
||||
onChange={(e) => setRefreshToken(e.target.value)}
|
||||
placeholder={t("refreshTokenPlaceholder")}
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 text-sm font-mono border border-border rounded-lg bg-background focus:outline-none focus:border-primary resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
{t("machineId")} <span className="text-text-muted text-xs">{t("optional")}</span>
|
||||
@@ -170,14 +359,12 @@ export default function CursorAuthModal({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Error Display */}
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleImportToken}
|
||||
@@ -186,11 +373,11 @@ export default function CursorAuthModal({
|
||||
>
|
||||
{importing ? t("importing") : t("importToken")}
|
||||
</Button>
|
||||
<Button onClick={onClose} variant="ghost" fullWidth>
|
||||
<Button onClick={handleClose} variant="ghost" fullWidth>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -172,6 +172,7 @@ export const oauthPasteCredentialsSchema = z.object({
|
||||
export const cursorImportSchema = z.object({
|
||||
accessToken: z.string().trim().min(1, "Access token is required"),
|
||||
machineId: z.string().trim().optional(),
|
||||
refreshToken: z.string().trim().min(1).optional(),
|
||||
});
|
||||
|
||||
export const traeImportSchema = z.object({
|
||||
|
||||
Reference in New Issue
Block a user