diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx index 997b8931d9..04e8205800 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx @@ -554,6 +554,10 @@ interface ConnectionRowProps { isApplyingCodexAuthLocal?: boolean; onExportCodexAuthFile?: () => void; isExportingCodexAuthFile?: boolean; + onApplyClaudeAuthLocal?: () => void; + isApplyingClaudeAuthLocal?: boolean; + onExportClaudeAuthFile?: () => void; + isExportingClaudeAuthFile?: boolean; } interface AddApiKeyModalProps { @@ -1063,6 +1067,12 @@ export default function ProviderDetailPage() { ); const [exportingCodexAuthId, setExportingCodexAuthId] = useState(null); const [importCodexModalOpen, setImportCodexModalOpen] = useState(false); + const [applyingClaudeAuthId, setApplyingClaudeAuthId] = useState(null); + const [applyClaudeModalConnectionId, setApplyClaudeModalConnectionId] = useState( + null + ); + const [exportingClaudeAuthId, setExportingClaudeAuthId] = useState(null); + const [importClaudeModalOpen, setImportClaudeModalOpen] = useState(false); const [codexGlobalFastServiceTier, setCodexGlobalFastServiceTier] = useState(false); const [savingCodexGlobalFastServiceTier, setSavingCodexGlobalFastServiceTier] = useState(false); const [selectedIds, setSelectedIds] = useState>(new Set()); @@ -2271,6 +2281,83 @@ export default function ProviderDetailPage() { } }; + const handleApplyClaudeAuthLocal = async (connectionId: string) => { + if (applyingClaudeAuthId) return; + setApplyingClaudeAuthId(connectionId); + + const defaultSuccess = + typeof t.has === "function" && t.has("claudeAuthAppliedLocal") + ? t("claudeAuthAppliedLocal") + : "Claude auth applied locally"; + const defaultError = + typeof t.has === "function" && t.has("claudeAuthApplyFailed") + ? t("claudeAuthApplyFailed") + : "Failed to apply Claude auth locally"; + + try { + const res = await fetch(`/api/providers/${connectionId}/claude-auth/apply-local`, { + method: "POST", + }); + + if (!res.ok) { + notify.error(await parseApiErrorMessage(res, defaultError)); + return; + } + + notify.success(defaultSuccess); + setApplyClaudeModalConnectionId(null); + } catch (error) { + console.error("Error applying Claude auth locally:", error); + notify.error(defaultError); + } finally { + setApplyingClaudeAuthId(null); + } + }; + + const handleExportClaudeAuthFile = async (connectionId: string) => { + if (exportingClaudeAuthId) return; + setExportingClaudeAuthId(connectionId); + + const defaultSuccess = + typeof t.has === "function" && t.has("claudeAuthExported") + ? t("claudeAuthExported") + : "Claude auth file exported"; + const defaultError = + typeof t.has === "function" && t.has("claudeAuthExportFailed") + ? t("claudeAuthExportFailed") + : "Failed to export Claude auth file"; + + try { + const res = await fetch(`/api/providers/${connectionId}/claude-auth/export`, { + method: "POST", + }); + + if (!res.ok) { + notify.error(await parseApiErrorMessage(res, defaultError)); + return; + } + + const blob = await res.blob(); + const filename = getAttachmentFilename(res, "claude-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 Claude auth file:", error); + notify.error(defaultError); + } finally { + setExportingClaudeAuthId(null); + } + }; + const handleSwapPriority = async (conn1, conn2) => { if (!conn1 || !conn2) return; try { @@ -3289,6 +3376,18 @@ export default function ProviderDetailPage() { Experimental OAuth )} + {providerId === "claude" && ( + + )} )} @@ -3355,6 +3454,17 @@ export default function ProviderDetailPage() { : "Import auth"} )} + {providerId === "claude" && ( + + )} )} @@ -3465,6 +3575,18 @@ export default function ProviderDetailPage() { : undefined } isExportingCodexAuthFile={exportingCodexAuthId === conn.id} + onApplyClaudeAuthLocal={ + providerId === "claude" + ? () => setApplyClaudeModalConnectionId(conn.id) + : undefined + } + isApplyingClaudeAuthLocal={applyingClaudeAuthId === conn.id} + onExportClaudeAuthFile={ + providerId === "claude" + ? () => handleExportClaudeAuthFile(conn.id) + : undefined + } + isExportingClaudeAuthFile={exportingClaudeAuthId === conn.id} onProxy={() => setProxyTarget({ level: "key", @@ -3627,6 +3749,18 @@ export default function ProviderDetailPage() { : undefined } isExportingCodexAuthFile={exportingCodexAuthId === conn.id} + onApplyClaudeAuthLocal={ + providerId === "claude" + ? () => setApplyClaudeModalConnectionId(conn.id) + : undefined + } + isApplyingClaudeAuthLocal={applyingClaudeAuthId === conn.id} + onExportClaudeAuthFile={ + providerId === "claude" + ? () => handleExportClaudeAuthFile(conn.id) + : undefined + } + isExportingClaudeAuthFile={exportingClaudeAuthId === conn.id} onProxy={() => setProxyTarget({ level: "key", @@ -3823,6 +3957,27 @@ export default function ProviderDetailPage() { }} /> )} + {/* Claude Apply Auth Modal */} + {providerId === "claude" && applyClaudeModalConnectionId && ( + setApplyClaudeModalConnectionId(null)} + /> + )} + {/* Claude Import Auth Modal */} + {providerId === "claude" && importClaudeModalOpen && ( + setImportClaudeModalOpen(false)} + onSuccess={() => { + setImportClaudeModalOpen(false); + fetchData(); + }} + /> + )} {/* Batch Test Results Modal */} {batchTestResults && (
s.emailsVisible); @@ -5698,6 +5857,14 @@ function ConnectionRow({ typeof t.has === "function" && t.has("exportCodexAuthFile") ? t("exportCodexAuthFile") : "Export auth"; + const applyClaudeAuthLabel = + typeof t.has === "function" && t.has("applyClaudeAuthLocal") + ? t("applyClaudeAuthLocal") + : "Apply auth"; + const exportClaudeAuthLabel = + typeof t.has === "function" && t.has("exportClaudeAuthFile") + ? t("exportClaudeAuthFile") + : "Export auth"; // Use useState + useEffect for impure Date.now() to avoid calling during render const [isCooldown, setIsCooldown] = useState(false); @@ -6038,6 +6205,34 @@ function ConnectionRow({ {exportCodexAuthLabel} )} + {isClaude && onApplyClaudeAuthLocal && ( + + )} + {isClaude && onExportClaudeAuthFile && ( + + )} void; + onSuccess: () => void; +} + +type ClaudeImportTopTab = "single" | "bulk"; +type ClaudeBulkSubMode = "upload" | "paste" | "zip"; + +interface ClaudeBulkEntry { + name: string; + json: unknown; + parseError: string | null; + email: string | null; +} + +function extractEmailFromClaudeJson(json: unknown): string | null { + try { + const doc = json && typeof json === "object" ? (json as Record) : null; + if (!doc) return null; + const oauth = + doc.claudeAiOauth && typeof doc.claudeAiOauth === "object" + ? (doc.claudeAiOauth as Record) + : null; + if (!oauth) return null; + return null; // email comes from bootstrap, not the file + } catch { + return null; + } +} + +function previewClaudeJson(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 }; + const oauth = + doc.claudeAiOauth && typeof doc.claudeAiOauth === "object" + ? (doc.claudeAiOauth as Record) + : null; + if (!oauth || !oauth.accessToken || !oauth.refreshToken) return { valid: false, email: null }; + return { valid: true, email: null }; + } catch { + return { valid: false, email: null }; + } +} + +function ImportClaudeAuthModal({ onClose, onSuccess }: ImportClaudeAuthModalProps) { + 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("claudeImportInvalidJson") + ? t("claudeImportInvalidJson") + : "Could not parse the file as JSON" + ); + } + }; + reader.readAsText(file); + }; + + const handleSingleSubmit = async () => { + if (submitting) return; + setSubmitting(true); + try { + let rawJson: unknown; + if (singleSubTab === "upload") { + rawJson = singleJson; + } else { + try { + rawJson = JSON.parse(singlePasteText); + } catch { + notify.error( + typeof t.has === "function" && t.has("claudeImportInvalidJson") + ? t("claudeImportInvalidJson") + : "Could not parse the pasted content as JSON" + ); + return; + } + } + + const body = + singleSubTab === "paste" + ? { + source: { kind: "text", text: singlePasteText }, + name: singleName || undefined, + email: singleEmail || undefined, + overwriteExisting, + } + : { + source: { kind: "json", json: rawJson }, + name: singleName || undefined, + email: singleEmail || undefined, + overwriteExisting, + }; + + const res = await fetch("/api/providers/claude-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("claudeImportDuplicate") + ? t("claudeImportDuplicate") + : "Account already exists — enable \"Replace existing\" to overwrite" + ); + } else if (data.code === "identity_unverified") { + notify.error( + typeof t.has === "function" && t.has("claudeImportIdentityUnverified") + ? t("claudeImportIdentityUnverified") + : "Bootstrap could not verify the account. Enable \"Replace existing\" or provide an email." + ); + } else { + notify.error( + data.error || + (typeof t.has === "function" && t.has("claudeImportFailed") + ? t("claudeImportFailed") + : "Failed to import Claude auth") + ); + } + return; + } + + notify.success( + typeof t.has === "function" && t.has("claudeImportSuccess") + ? t("claudeImportSuccess") + : "Claude connection imported successfully" + ); + onSuccess(); + } catch { + notify.error( + typeof t.has === "function" && t.has("claudeImportFailed") + ? t("claudeImportFailed") + : "Failed to import Claude auth" + ); + } finally { + setSubmitting(false); + } + }; + + const handleBulkFilesChange = (e: React.ChangeEvent) => { + const files = Array.from(e.target.files || []); + const newEntries: ClaudeBulkEntry[] = []; + 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 = extractEmailFromClaudeJson(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) => ({ name: `entry ${i + 1}`, json: item, parseError: null, email: null })) + ); + } else { + setBulkEntries([{ name: "entry 1", json: arr, parseError: null, email: null }]); + } + } 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/claude-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("claudeImportBulkZipError") + ? t("claudeImportBulkZipError") + : "Failed to extract ZIP") + ); + return; + } + const entries: ClaudeBulkEntry[] = (data.entries || []).map( + (e: { name: string; json: unknown; parseError: string | null }) => ({ + name: e.name, + json: e.json, + parseError: e.parseError, + email: null, + }) + ); + setBulkEntries(entries); + } catch { + notify.error( + typeof t.has === "function" && t.has("claudeImportBulkZipError") + ? t("claudeImportBulkZipError") + : "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/claude-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("claudeImportBulkFailed") + ? t("claudeImportBulkFailed") + : "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("claudeImportBulkSuccess") + ? t("claudeImportBulkSuccess", { count: data.success }) + : `Imported ${data.success} Claude connections` + ); + if (data.failed === 0) onSuccess(); + } + } catch { + notify.error( + typeof t.has === "function" && t.has("claudeImportBulkFailed") + ? t("claudeImportBulkFailed") + : "Some entries failed to import" + ); + } finally { + setBulkSubmitting(false); + } + }; + + const tabLabels: Record = { + single: + typeof t.has === "function" && t.has("claudeImportTabSingle") + ? t("claudeImportTabSingle") + : "Single", + bulk: + typeof t.has === "function" && t.has("claudeImportTabBulk") + ? t("claudeImportTabBulk") + : "Bulk", + }; + + const modalTitle = + typeof t.has === "function" && t.has("claudeImportModalTitle") + ? t("claudeImportModalTitle") + : "Import Claude Auth"; + + return ( + +
+ {/* Top tabs */} +
+ {(["single", "bulk"] as ClaudeImportTopTab[]).map((tab) => ( + + ))} +
+ + {topTab === "single" && ( +
+ {/* Sub-tabs */} +
+ {(["upload", "paste"] as const).map((sub) => ( + + ))} +
+ {singleSubTab === "upload" ? ( +
+ + + {singleJson && previewClaudeJson(singleJson).valid && ( +

Valid Claude credentials file

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

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

+ )} +
+ ) : ( +
+ +