diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx index 997b8931d9..38fb6e5cd6 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx @@ -524,6 +524,7 @@ interface ConnectionRowProps { isOAuth: boolean; isClaude?: boolean; isCodex?: boolean; + isGeminiCli?: boolean; codexFastGlobalEnabled?: boolean; isFirst: boolean; isLast: boolean; @@ -554,6 +555,10 @@ interface ConnectionRowProps { isApplyingCodexAuthLocal?: boolean; onExportCodexAuthFile?: () => void; isExportingCodexAuthFile?: boolean; + onApplyGeminiAuthLocal?: () => void; + isApplyingGeminiAuthLocal?: boolean; + onExportGeminiAuthFile?: () => void; + isExportingGeminiAuthFile?: boolean; } interface AddApiKeyModalProps { @@ -1063,6 +1068,12 @@ export default function ProviderDetailPage() { ); const [exportingCodexAuthId, setExportingCodexAuthId] = useState(null); const [importCodexModalOpen, setImportCodexModalOpen] = useState(false); + const [applyingGeminiAuthId, setApplyingGeminiAuthId] = useState(null); + const [applyGeminiModalConnectionId, setApplyGeminiModalConnectionId] = useState( + null + ); + const [exportingGeminiAuthId, setExportingGeminiAuthId] = useState(null); + const [importGeminiModalOpen, setImportGeminiModalOpen] = useState(false); const [codexGlobalFastServiceTier, setCodexGlobalFastServiceTier] = useState(false); const [savingCodexGlobalFastServiceTier, setSavingCodexGlobalFastServiceTier] = useState(false); const [selectedIds, setSelectedIds] = useState>(new Set()); @@ -2271,6 +2282,83 @@ export default function ProviderDetailPage() { } }; + const handleApplyGeminiAuthLocal = async (connectionId: string) => { + if (applyingGeminiAuthId) return; + setApplyingGeminiAuthId(connectionId); + + const defaultSuccess = + typeof t.has === "function" && t.has("geminiAuthAppliedLocal") + ? t("geminiAuthAppliedLocal") + : "Gemini auth applied locally"; + const defaultError = + typeof t.has === "function" && t.has("geminiAuthApplyFailed") + ? t("geminiAuthApplyFailed") + : "Failed to apply Gemini auth locally"; + + try { + const res = await fetch(`/api/providers/${connectionId}/gemini-cli-auth/apply-local`, { + method: "POST", + }); + + if (!res.ok) { + notify.error(await parseApiErrorMessage(res, defaultError)); + return; + } + + notify.success(defaultSuccess); + setApplyGeminiModalConnectionId(null); + } catch (error) { + console.error("Error applying Gemini auth locally:", error); + notify.error(defaultError); + } finally { + setApplyingGeminiAuthId(null); + } + }; + + const handleExportGeminiAuthFile = async (connectionId: string) => { + if (exportingGeminiAuthId) return; + setExportingGeminiAuthId(connectionId); + + const defaultSuccess = + typeof t.has === "function" && t.has("geminiAuthExported") + ? t("geminiAuthExported") + : "Gemini auth file exported"; + const defaultError = + typeof t.has === "function" && t.has("geminiAuthExportFailed") + ? t("geminiAuthExportFailed") + : "Failed to export Gemini auth file"; + + try { + const res = await fetch(`/api/providers/${connectionId}/gemini-cli-auth/export`, { + method: "POST", + }); + + if (!res.ok) { + notify.error(await parseApiErrorMessage(res, defaultError)); + return; + } + + const blob = await res.blob(); + const filename = getAttachmentFilename(res, "gemini-auth.json"); + const objectUrl = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + + link.href = objectUrl; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.setTimeout(() => window.URL.revokeObjectURL(objectUrl), 1000); + + notify.success(defaultSuccess); + } catch (error) { + console.error("Error exporting Gemini auth file:", error); + notify.error(defaultError); + } finally { + setExportingGeminiAuthId(null); + } + }; + const handleSwapPriority = async (conn1, conn2) => { if (!conn1 || !conn2) return; try { @@ -3289,6 +3377,18 @@ export default function ProviderDetailPage() { Experimental OAuth )} + {providerId === "gemini-cli" && ( + + )} )} @@ -3355,6 +3455,17 @@ export default function ProviderDetailPage() { : "Import auth"} )} + {providerId === "gemini-cli" && ( + + )} )} @@ -3424,6 +3535,7 @@ export default function ProviderDetailPage() { handleToggleClaudeExtraUsage(conn.id, enabled) } isCodex={providerId === "codex"} + isGeminiCli={providerId === "gemini-cli"} isCcCompatible={isCcCompatible} cliproxyapiEnabled={cpaProviderEnabled} onToggleCliproxyapiMode={(enabled) => @@ -3465,6 +3577,18 @@ export default function ProviderDetailPage() { : undefined } isExportingCodexAuthFile={exportingCodexAuthId === conn.id} + onApplyGeminiAuthLocal={ + providerId === "gemini-cli" + ? () => setApplyGeminiModalConnectionId(conn.id) + : undefined + } + isApplyingGeminiAuthLocal={applyingGeminiAuthId === conn.id} + onExportGeminiAuthFile={ + providerId === "gemini-cli" + ? () => handleExportGeminiAuthFile(conn.id) + : undefined + } + isExportingGeminiAuthFile={exportingGeminiAuthId === conn.id} onProxy={() => setProxyTarget({ level: "key", @@ -3589,6 +3713,7 @@ export default function ProviderDetailPage() { handleToggleClaudeExtraUsage(conn.id, enabled) } isCodex={providerId === "codex"} + isGeminiCli={providerId === "gemini-cli"} isCcCompatible={isCcCompatible} cliproxyapiEnabled={cpaProviderEnabled} onToggleCodex5h={(enabled) => @@ -3627,6 +3752,18 @@ export default function ProviderDetailPage() { : undefined } isExportingCodexAuthFile={exportingCodexAuthId === conn.id} + onApplyGeminiAuthLocal={ + providerId === "gemini-cli" + ? () => setApplyGeminiModalConnectionId(conn.id) + : undefined + } + isApplyingGeminiAuthLocal={applyingGeminiAuthId === conn.id} + onExportGeminiAuthFile={ + providerId === "gemini-cli" + ? () => handleExportGeminiAuthFile(conn.id) + : undefined + } + isExportingGeminiAuthFile={exportingGeminiAuthId === conn.id} onProxy={() => setProxyTarget({ level: "key", @@ -3823,6 +3960,27 @@ export default function ProviderDetailPage() { }} /> )} + {/* Gemini Apply Auth Modal */} + {providerId === "gemini-cli" && applyGeminiModalConnectionId && ( + setApplyGeminiModalConnectionId(null)} + /> + )} + {/* Gemini Import Auth Modal */} + {providerId === "gemini-cli" && importGeminiModalOpen && ( + setImportGeminiModalOpen(false)} + onSuccess={() => { + setImportGeminiModalOpen(false); + fetchData(); + }} + /> + )} {/* Batch Test Results Modal */} {batchTestResults && (
s.emailsVisible); @@ -5698,6 +5861,14 @@ function ConnectionRow({ typeof t.has === "function" && t.has("exportCodexAuthFile") ? t("exportCodexAuthFile") : "Export auth"; + const applyGeminiAuthLabel = + typeof t.has === "function" && t.has("applyGeminiAuthLocal") + ? t("applyGeminiAuthLocal") + : "Apply auth"; + const exportGeminiAuthLabel = + typeof t.has === "function" && t.has("exportGeminiAuthFile") + ? t("exportGeminiAuthFile") + : "Export auth"; // Use useState + useEffect for impure Date.now() to avoid calling during render const [isCooldown, setIsCooldown] = useState(false); @@ -6038,6 +6209,34 @@ function ConnectionRow({ {exportCodexAuthLabel} )} + {isGeminiCli && onApplyGeminiAuthLocal && ( + + )} + {isGeminiCli && onExportGeminiAuthFile && ( + + )} ); } + +// ──── ImportGeminiAuthModal ──────────────────────────────────────────────────── + +interface ImportGeminiAuthModalProps { + onClose: () => void; + onSuccess: () => void; +} + +type GeminiImportTopTab = "single" | "bulk"; +type GeminiBulkSubMode = "upload" | "paste" | "zip"; + +interface GeminiBulkEntry { + name: string; + json: unknown; + parseError: string | null; + email: string | null; +} + +function extractEmailFromGeminiJwt(idToken: string): string | null { + try { + const parts = idToken.split("."); + if (parts.length !== 3) return null; + const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8")); + return typeof payload.email === "string" ? payload.email : null; + } catch { + return null; + } +} + +function previewGeminiJson(json: unknown): { valid: boolean; email: string | null } { + try { + const doc = json && typeof json === "object" ? (json as Record) : null; + if (!doc) return { valid: false, email: null }; + if (!doc.access_token || !doc.refresh_token || !doc.id_token) + return { valid: false, email: null }; + const email = + typeof doc.id_token === "string" ? extractEmailFromGeminiJwt(doc.id_token) : null; + return { valid: true, email }; + } catch { + return { valid: false, email: null }; + } +} + +function ImportGeminiAuthModal({ onClose, onSuccess }: ImportGeminiAuthModalProps) { + const t = useTranslations("providers"); + const notify = useNotificationStore(); + + const [topTab, setTopTab] = useState("single"); + const [singleSubTab, setSingleSubTab] = useState<"upload" | "paste">("upload"); + const [bulkSubMode, setBulkSubMode] = useState("upload"); + + // Single + const [singleJson, setSingleJson] = useState(null); + const [singlePasteText, setSinglePasteText] = useState(""); + const [singleName, setSingleName] = useState(""); + const [singleEmail, setSingleEmail] = useState(""); + const [overwriteExisting, setOverwriteExisting] = useState(false); + const [submitting, setSubmitting] = useState(false); + + // Bulk + const [bulkEntries, setBulkEntries] = useState([]); + const [bulkPasteText, setBulkPasteText] = useState(""); + const [bulkSubmitting, setBulkSubmitting] = useState(false); + const [bulkErrors, setBulkErrors] = useState< + { index: number; name: string; message: string }[] + >([]); + const [bulkResult, setBulkResult] = useState<{ + success: number; + failed: number; + total: number; + } | null>(null); + const [zipExtracting, setZipExtracting] = useState(false); + + const handleSingleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = (ev) => { + try { + const json = JSON.parse(ev.target?.result as string); + setSingleJson(json); + } catch { + notify.error( + typeof t.has === "function" && t.has("geminiImportInvalidJson") + ? t("geminiImportInvalidJson") + : "Could not parse the file as JSON" + ); + } + }; + reader.readAsText(file); + }; + + const handleSingleSubmit = async () => { + if (submitting) return; + setSubmitting(true); + try { + const body = + singleSubTab === "paste" + ? { + source: { kind: "text", text: singlePasteText }, + name: singleName || undefined, + email: singleEmail || undefined, + overwriteExisting, + } + : { + source: { kind: "json", json: singleJson }, + name: singleName || undefined, + email: singleEmail || undefined, + overwriteExisting, + }; + + const res = await fetch("/api/providers/gemini-cli-auth/import", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + const data = await res.json().catch(() => ({})); + + if (!res.ok) { + if (data.code === "duplicate_account") { + notify.error( + typeof t.has === "function" && t.has("geminiImportDuplicate") + ? t("geminiImportDuplicate") + : "Account already exists — enable \"Replace existing\" to overwrite" + ); + } else if (data.code === "identity_unverified") { + notify.error( + typeof t.has === "function" && t.has("geminiImportIdentityUnverified") + ? t("geminiImportIdentityUnverified") + : "Could not verify identity from id_token. Enable \"Replace existing\" or provide an email." + ); + } else { + notify.error( + data.error || + (typeof t.has === "function" && t.has("geminiImportFailed") + ? t("geminiImportFailed") + : "Failed to import Gemini auth") + ); + } + return; + } + + const preview = previewGeminiJson(singleJson); + notify.success( + typeof t.has === "function" && t.has("geminiImportSuccess") + ? t("geminiImportSuccess") + : `Gemini connection imported successfully${preview.email ? ` (${preview.email})` : ""}` + ); + onSuccess(); + } catch { + notify.error( + typeof t.has === "function" && t.has("geminiImportFailed") + ? t("geminiImportFailed") + : "Failed to import Gemini auth" + ); + } finally { + setSubmitting(false); + } + }; + + const handleBulkFilesChange = (e: React.ChangeEvent) => { + const files = Array.from(e.target.files || []); + const newEntries: GeminiBulkEntry[] = []; + let pending = files.length; + if (!pending) return; + files.forEach((file) => { + const reader = new FileReader(); + reader.onload = (ev) => { + try { + const json = JSON.parse(ev.target?.result as string); + const { email } = previewGeminiJson(json); + newEntries.push({ + name: file.name.replace(/\.json$/, ""), + json, + parseError: null, + email, + }); + } catch { + newEntries.push({ + name: file.name, + json: null, + parseError: "Not valid JSON", + email: null, + }); + } + pending--; + if (pending === 0) setBulkEntries((prev) => [...prev, ...newEntries]); + }; + reader.readAsText(file); + }); + }; + + const handleBulkPasteChange = (text: string) => { + setBulkPasteText(text); + const trimmed = text.trim(); + if (!trimmed) { + setBulkEntries([]); + return; + } + try { + const arr = JSON.parse(trimmed); + if (Array.isArray(arr)) { + setBulkEntries( + arr.map((item, i) => { + const { email } = previewGeminiJson(item); + return { name: email || `entry ${i + 1}`, json: item, parseError: null, email }; + }) + ); + } else { + const { email } = previewGeminiJson(arr); + setBulkEntries([{ name: email || "entry 1", json: arr, parseError: null, email }]); + } + } catch { + setBulkEntries([ + { name: "parse error", json: null, parseError: "Invalid JSON", email: null }, + ]); + } + }; + + const handleZipUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + setZipExtracting(true); + try { + const res = await fetch("/api/providers/gemini-cli-auth/zip-extract", { + method: "POST", + headers: { "Content-Type": "application/zip" }, + body: file, + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + notify.error( + data.error || + (typeof t.has === "function" && t.has("geminiImportBulkZipError") + ? t("geminiImportBulkZipError") + : "Failed to extract ZIP") + ); + return; + } + const entries: GeminiBulkEntry[] = (data.entries || []).map( + (e: { name: string; json: unknown; parseError: string | null }) => { + const { email } = previewGeminiJson(e.json); + return { name: e.name, json: e.json, parseError: e.parseError, email }; + } + ); + setBulkEntries(entries); + } catch { + notify.error( + typeof t.has === "function" && t.has("geminiImportBulkZipError") + ? t("geminiImportBulkZipError") + : "Failed to extract ZIP" + ); + } finally { + setZipExtracting(false); + } + }; + + const handleBulkSubmit = async () => { + if (bulkSubmitting) return; + setBulkSubmitting(true); + setBulkErrors([]); + setBulkResult(null); + try { + const validEntries = bulkEntries.filter((e) => e.json !== null); + if (validEntries.length === 0) { + notify.error("No valid entries to import"); + return; + } + const res = await fetch("/api/providers/gemini-cli-auth/import-bulk", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + entries: validEntries.map((e) => ({ + json: e.json, + name: e.name, + email: e.email || undefined, + })), + overwriteExisting, + }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + notify.error( + data.error || + (typeof t.has === "function" && t.has("geminiImportBulkFailed") + ? t("geminiImportBulkFailed") + : "Some entries failed to import") + ); + return; + } + setBulkResult({ success: data.success, failed: data.failed, total: data.total }); + if (data.errors?.length > 0) setBulkErrors(data.errors); + if (data.success > 0) { + notify.success( + typeof t.has === "function" && t.has("geminiImportBulkSuccess") + ? t("geminiImportBulkSuccess", { count: data.success }) + : `Imported ${data.success} Gemini connections` + ); + if (data.failed === 0) onSuccess(); + } + } catch { + notify.error( + typeof t.has === "function" && t.has("geminiImportBulkFailed") + ? t("geminiImportBulkFailed") + : "Some entries failed to import" + ); + } finally { + setBulkSubmitting(false); + } + }; + + const tabLabels: Record = { + single: + typeof t.has === "function" && t.has("geminiImportTabSingle") + ? t("geminiImportTabSingle") + : "Single", + bulk: + typeof t.has === "function" && t.has("geminiImportTabBulk") + ? t("geminiImportTabBulk") + : "Bulk", + }; + + const modalTitle = + typeof t.has === "function" && t.has("geminiImportModalTitle") + ? t("geminiImportModalTitle") + : "Import Gemini Auth"; + + return ( + +
+
+ {(["single", "bulk"] as GeminiImportTopTab[]).map((tab) => ( + + ))} +
+ + {topTab === "single" && ( +
+
+ {(["upload", "paste"] as const).map((sub) => ( + + ))} +
+ {singleSubTab === "upload" ? ( +
+ + + {singleJson && previewGeminiJson(singleJson).valid && ( +

+ Valid Gemini OAuth credentials + {previewGeminiJson(singleJson).email + ? ` (${previewGeminiJson(singleJson).email})` + : ""} +

+ )} + {singleJson && !previewGeminiJson(singleJson).valid && ( +

+ {typeof t.has === "function" && t.has("geminiImportInvalidShape") + ? t("geminiImportInvalidShape") + : "The file is not a valid oauth_creds.json"} +

+ )} +
+ ) : ( +
+ +