diff --git a/package-lock.json b/package-lock.json index 54afbe5ad1..b8f0baedcc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "csv-stringify": "^6.7.0", "express": "^5.2.1", "fetch-socks": "^1.3.3", + "fflate": "^0.8.3", "fuse.js": "^7.3.0", "gray-matter": "^4.0.3", "http-proxy-middleware": "^4.0.0", @@ -9177,6 +9178,12 @@ "undici": ">=7" } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", diff --git a/package.json b/package.json index 2587a07474..ac929b393a 100644 --- a/package.json +++ b/package.json @@ -145,6 +145,7 @@ "csv-stringify": "^6.7.0", "express": "^5.2.1", "fetch-socks": "^1.3.3", + "fflate": "^0.8.3", "fuse.js": "^7.3.0", "gray-matter": "^4.0.3", "http-proxy-middleware": "^4.0.0", diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx index a850433439..997b8931d9 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx @@ -3815,7 +3815,7 @@ export default function ProviderDetailPage() { {/* Codex Import Auth Modal */} {providerId === "codex" && importCodexModalOpen && ( setImportCodexModalOpen(false)} onSuccess={() => { setImportCodexModalOpen(false); @@ -7008,135 +7008,313 @@ function AddApiKeyModal({ ); } +// ──── ImportCodexAuthModal ──────────────────────────────────────────────────── + interface ImportCodexAuthModalProps { onClose: () => void; onSuccess: () => void; } +type ImportTopTab = "single" | "bulk"; +type BulkSubMode = "upload" | "paste" | "zip"; + +interface BulkEntry { + name: string; + json: unknown; + parseError: string | null; + email: string | null; +} + +function extractEmailFromJwtLocal(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 previewCodexJson(json: unknown): { valid: boolean; email: string | null } { + try { + const doc = json && typeof json === "object" ? (json as Record) : null; + if (!doc || doc.auth_mode !== "chatgpt") return { valid: false, email: null }; + const tokens = + doc.tokens && typeof doc.tokens === "object" ? (doc.tokens as Record) : null; + if (!tokens?.id_token || typeof tokens.id_token !== "string") + return { valid: false, email: null }; + return { valid: true, email: extractEmailFromJwtLocal(tokens.id_token as string) }; + } catch { + return { valid: false, email: null }; + } +} + +function parseBulkPasteText(text: string): BulkEntry[] { + const trimmed = text.trim(); + if (!trimmed) return []; + + const tryParse = (s: string): BulkEntry => { + try { + const json = JSON.parse(s); + const { email } = previewCodexJson(json); + return { name: email || "unknown", json, parseError: null, email }; + } catch { + return { name: "parse error", json: null, parseError: "Invalid JSON", email: null }; + } + }; + + try { + const arr = JSON.parse(trimmed); + if (Array.isArray(arr)) + return arr.map((item) => { + const { email } = previewCodexJson(item); + return { name: email || "unknown", json: item, parseError: null, email }; + }); + const { email } = previewCodexJson(arr); + return [{ name: email || "unknown", json: arr, parseError: null, email }]; + } catch { + return trimmed + .split(/^---$/m) + .map((s) => tryParse(s.trim())) + .filter((e) => e.json !== null || e.parseError !== null); + } +} + function ImportCodexAuthModal({ onClose, onSuccess }: ImportCodexAuthModalProps) { const t = useTranslations("providers"); const notify = useNotificationStore(); - const [tab, setTab] = useState<"upload" | "paste">("upload"); - const [pasteText, setPasteText] = useState(""); - const [parsedJson, setParsedJson] = useState(null); - const [parseError, setParseError] = useState(null); - const [detectedEmail, setDetectedEmail] = useState(null); - const [name, setName] = useState(""); - const [email, setEmail] = useState(""); - const [overwriteExisting, setOverwriteExisting] = useState(false); - const [loading, setLoading] = useState(false); - const [resultError, setResultError] = useState(null); - function extractEmailFromJwt(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; + // Top-level tab: Single / Bulk + const [topTab, setTopTab] = useState("single"); + + // ── Single mode state ── + const [singleTab, setSingleTab] = useState<"upload" | "paste">("upload"); + const [singleParsedJson, setSingleParsedJson] = useState(null); + const [singleParseError, setSingleParseError] = useState(null); + const [singleDetectedEmail, setSingleDetectedEmail] = useState(null); + const [singlePasteText, setSinglePasteText] = useState(""); + const [singleName, setSingleName] = useState(""); + const [singleEmail, setSingleEmail] = useState(""); + const [singleOverwrite, setSingleOverwrite] = useState(false); + const [singleLoading, setSingleLoading] = useState(false); + const [singleError, setSingleError] = useState(null); + + // ── Bulk mode state ── + const [bulkMode, setBulkMode] = useState("upload"); + const [bulkEntries, setBulkEntries] = useState([]); + const [bulkPasteText, setBulkPasteText] = useState(""); + const [bulkZipExtracting, setBulkZipExtracting] = useState(false); + const [bulkZipError, setBulkZipError] = useState(null); + const [bulkOverwrite, setBulkOverwrite] = useState(false); + const [bulkLoading, setBulkLoading] = useState(false); + const [bulkResult, setBulkResult] = useState<{ + success: number; + failed: number; + errors: { index: number; name: string; message: string }[]; + } | null>(null); + + // ── Single helpers ── + + function handleSinglePreview(json: unknown) { + setSingleParseError(null); + setSingleDetectedEmail(null); + setSingleParsedJson(null); + const { valid, email } = previewCodexJson(json); + if (!valid) { + setSingleParseError(t("codexImportInvalidShape") || "Not a valid Codex auth.json"); + return; } + setSingleDetectedEmail(email); + if (email && !singleEmail) setSingleEmail(email); + setSingleParsedJson(json); } - function tryParseAndPreview(json: unknown) { - setParseError(null); - setDetectedEmail(null); - setParsedJson(null); - try { - const doc = json && typeof json === "object" ? (json as Record) : null; - if (!doc || doc.auth_mode !== "chatgpt") { - setParseError(t("codexImportInvalidShape") || "Not a valid Codex auth.json"); - return; - } - const tokens = - doc.tokens && typeof doc.tokens === "object" - ? (doc.tokens as Record) - : null; - if (!tokens?.id_token || typeof tokens.id_token !== "string") { - setParseError(t("codexImportInvalidShape") || "Not a valid Codex auth.json"); - return; - } - const detected = extractEmailFromJwt(tokens.id_token as string); - setDetectedEmail(detected); - if (detected && !email) setEmail(detected); - setParsedJson(doc); - } catch { - setParseError(t("codexImportInvalidJson") || "Could not parse JSON"); - } - } - - function handleFileChange(e: React.ChangeEvent) { + function handleSingleFileChange(e: React.ChangeEvent) { const file = e.target.files?.[0]; if (!file) return; const reader = new FileReader(); reader.onload = (ev) => { - const text = ev.target?.result as string; try { - const json = JSON.parse(text); - tryParseAndPreview(json); - setParsedJson(json); + handleSinglePreview(JSON.parse(ev.target?.result as string)); } catch { - setParseError(t("codexImportInvalidJson") || "Could not parse JSON"); - setParsedJson(null); + setSingleParseError(t("codexImportInvalidJson") || "Could not parse JSON"); } }; reader.readAsText(file); } - function handlePasteChange(text: string) { - setPasteText(text); + function handleSinglePasteChange(text: string) { + setSinglePasteText(text); if (!text.trim()) { - setParsedJson(null); - setParseError(null); - setDetectedEmail(null); + setSingleParsedJson(null); + setSingleParseError(null); + setSingleDetectedEmail(null); return; } try { - const json = JSON.parse(text); - tryParseAndPreview(json); + handleSinglePreview(JSON.parse(text)); } catch { - setParseError(t("codexImportInvalidJson") || "Could not parse JSON"); - setParsedJson(null); + setSingleParseError(t("codexImportInvalidJson") || "Could not parse JSON"); + setSingleParsedJson(null); } } - async function handleSubmit() { - if (!parsedJson) return; - setLoading(true); - setResultError(null); + async function handleSingleSubmit() { + if (!singleParsedJson) return; + setSingleLoading(true); + setSingleError(null); try { const res = await fetch("/api/providers/codex-auth/import", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - source: { kind: "json", json: parsedJson }, - name: name.trim() || undefined, - email: email.trim() || undefined, - overwriteExisting, + source: { kind: "json", json: singleParsedJson }, + name: singleName.trim() || undefined, + email: singleEmail.trim() || undefined, + overwriteExisting: singleOverwrite, }), }); const data = await res.json().catch(() => ({})); if (!res.ok) { - if (data.code === "duplicate_account") { - setResultError( - t("codexImportDuplicate") || - "Account already exists — enable Replace existing to overwrite" - ); - } else { - setResultError(data.error || t("codexImportFailed") || "Failed to import Codex auth"); - } + setSingleError( + data.code === "duplicate_account" + ? t("codexImportDuplicate") || + "Account already exists — enable Replace existing to overwrite" + : data.error || t("codexImportFailed") || "Failed to import" + ); return; } notify.success(t("codexImportSuccess") || "Codex connection imported successfully"); onSuccess(); } catch { - setResultError(t("codexImportFailed") || "Failed to import Codex auth"); + setSingleError(t("codexImportFailed") || "Failed to import Codex auth"); } finally { - setLoading(false); + setSingleLoading(false); } } - const canSubmit = !!parsedJson && !parseError && !loading; + // ── Bulk helpers ── + + function handleBulkFilesChange(e: React.ChangeEvent) { + const files = Array.from(e.target.files || []); + const entries: BulkEntry[] = []; + let pending = files.length; + if (pending === 0) return; + files.forEach((file) => { + const reader = new FileReader(); + reader.onload = (ev) => { + try { + const json = JSON.parse(ev.target?.result as string); + const { email } = previewCodexJson(json); + entries.push({ + name: email || file.name.replace(".json", ""), + json, + parseError: null, + email, + }); + } catch { + entries.push({ name: file.name, json: null, parseError: "Invalid JSON", email: null }); + } + if (--pending === 0) setBulkEntries([...entries]); + }; + reader.readAsText(file); + }); + } + + function handleBulkPasteChange(text: string) { + setBulkPasteText(text); + if (!text.trim()) { + setBulkEntries([]); + return; + } + setBulkEntries(parseBulkPasteText(text)); + } + + async function handleZipUpload(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (!file) return; + setBulkZipExtracting(true); + setBulkZipError(null); + setBulkEntries([]); + try { + const res = await fetch("/api/providers/codex-auth/zip-extract", { + method: "POST", + headers: { "Content-Type": "application/octet-stream" }, + body: file, + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + setBulkZipError(data.error || t("codexImportBulkZipError") || "Failed to extract ZIP"); + return; + } + const extracted: BulkEntry[] = (data.entries || []).map( + (entry: { name: string; json: unknown; parseError: string | null }) => { + if (entry.parseError) + return { name: entry.name, json: null, parseError: entry.parseError, email: null }; + const { email } = previewCodexJson(entry.json); + return { + name: email || entry.name.replace(".json", ""), + json: entry.json, + parseError: null, + email, + }; + } + ); + setBulkEntries(extracted); + } catch { + setBulkZipError(t("codexImportBulkZipError") || "Failed to extract ZIP"); + } finally { + setBulkZipExtracting(false); + } + } + + async function handleBulkSubmit() { + const validEntries = bulkEntries.filter((e) => !e.parseError && e.json !== null); + if (validEntries.length === 0) return; + setBulkLoading(true); + setBulkResult(null); + try { + const res = await fetch("/api/providers/codex-auth/import-bulk", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + entries: validEntries.map((e) => ({ + json: e.json, + name: e.name || undefined, + email: e.email || undefined, + })), + overwriteExisting: bulkOverwrite, + }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + notify.error(data.error || t("codexImportFailed") || "Failed to import"); + return; + } + setBulkResult({ success: data.success, failed: data.failed, errors: data.errors || [] }); + if (data.success > 0) onSuccess(); + } catch { + notify.error(t("codexImportFailed") || "Failed to import Codex auth"); + } finally { + setBulkLoading(false); + } + } + + const singleCanSubmit = !!singleParsedJson && !singleParseError && !singleLoading; + const validBulkCount = bulkEntries.filter((e) => !e.parseError && e.json !== null).length; + const bulkCanSubmit = validBulkCount > 0 && !bulkLoading && !bulkZipExtracting; + + const TOP_TABS: { id: ImportTopTab; label: string }[] = [ + { id: "single", label: t("codexImportTabSingle") || "Single" }, + { id: "bulk", label: t("codexImportTabBulk") || "Bulk" }, + ]; + + const BULK_MODES: { id: BulkSubMode; label: string }[] = [ + { id: "upload", label: t("codexImportBulkModeUpload") || "Upload files" }, + { id: "paste", label: t("codexImportBulkModePaste") || "Paste list" }, + { id: "zip", label: t("codexImportBulkModeZip") || "ZIP archive" }, + ]; return (
- {/* Tabs */} + {/* Top-level Single / Bulk tabs */}
- {(["upload", "paste"] as const).map((id) => ( + {TOP_TABS.map(({ id, label }) => ( ))}
- {/* Upload tab */} - {tab === "upload" && ( -
- - -

- {t("codexImportFileHint") || - "Select the auth.json file exported from Codex or OmniRoute."} -

-
+ {/* ── Single tab ── */} + {topTab === "single" && ( + <> + {/* Source sub-tabs */} +
+ {(["upload", "paste"] as const).map((id) => ( + + ))} +
+ + {singleTab === "upload" && ( +
+ + +

+ {t("codexImportFileHint") || + "Select the auth.json file exported from Codex or OmniRoute."} +

+
+ )} + + {singleTab === "paste" && ( +
+ +