feat(providers/codex): bulk add accounts via JSON

Add a "Bulk Add" button on /dashboard/providers/codex that imports
multiple OAuth accounts at once by pasting a JSON array, single object,
or { accounts: [...] } wrapper.

- New endpoint POST /api/oauth/codex/bulk-import (serial loop, no token echo)
- New BulkImportCodexModal component with JSON textarea + success/failure summary
- Persist idToken/lastRefreshAt on first insert via OPTIONAL_FIELDS
- Backfill email/chatgptAccountId/chatgptPlanType from JWT when missing
- Derive expiresAt from expiresIn when missing
- Gated to providerId === "codex" only; other providers unaffected

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Quoc NguyenandCursor authored and decolua committed 2026-06-08 10:08:46 +07:00
1 parent b2aa08ad16
commit 8962e466d6
4 files changed
+299 -1

No files matched your search

@@ -0,0 +1,150 @@
"use client";
import { useState } from "react";
import PropTypes from "prop-types";
import { Button, Modal } from "@/shared/components";
import { translate } from "@/i18n/runtime";
const PLACEHOLDER = `[
{
"accessToken": "eyJhbGc...",
"refreshToken": "rt_...",
"idToken": "eyJhbGc...",
"email": "user@example.com"
}
]`;
function normalizeToArray(parsed) {
if (Array.isArray(parsed)) return parsed;
if (parsed && typeof parsed === "object") {
if (Array.isArray(parsed.accounts)) return parsed.accounts;
return [parsed];
}
return null;
}
export default function BulkImportCodexModal({ isOpen, onClose, onSuccess }) {
const [jsonText, setJsonText] = useState("");
const [submitting, setSubmitting] = useState(false);
const [parseError, setParseError] = useState("");
const [result, setResult] = useState(null);
const handleClose = () => {
if (submitting) return;
setJsonText("");
setParseError("");
setResult(null);
onClose();
};
const handleSubmit = async () => {
setParseError("");
setResult(null);
const trimmed = jsonText.trim();
if (!trimmed) return;
let parsed;
try {
parsed = JSON.parse(trimmed);
} catch (err) {
setParseError(`${translate("Invalid JSON")}: ${err.message}`);
return;
}
const accounts = normalizeToArray(parsed);
if (!accounts || accounts.length === 0) {
setParseError(translate("No accounts found in input"));
return;
}
setSubmitting(true);
try {
const res = await fetch("/api/oauth/codex/bulk-import", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ accounts }),
});
const data = await res.json();
if (!res.ok) {
setParseError(data?.error || `Request failed: ${res.status}`);
return;
}
setResult(data);
if (data.success > 0 && typeof onSuccess === "function") {
onSuccess();
}
} catch (err) {
setParseError(err.message || translate("Request failed"));
} finally {
setSubmitting(false);
}
};
const failedItems = result?.results?.filter((r) => !r.ok) || [];
return (
<Modal isOpen={isOpen} title={translate("Bulk Add Codex Accounts")} onClose={handleClose}>
<div className="flex flex-col gap-4">
<p className="text-xs text-text-muted">
{translate(
"Paste an array of codex account JSON objects. Each must include accessToken (and ideally refreshToken, idToken)."
)}
</p>
<textarea
className="w-full rounded border border-accent/30 bg-sidebar p-2 text-sm font-mono resize-y min-h-[240px] focus:outline-none focus:ring-1 focus:ring-primary"
placeholder={PLACEHOLDER}
value={jsonText}
onChange={(e) => setJsonText(e.target.value)}
disabled={submitting}
/>
{parseError && (
<p className="text-xs text-red-500 break-words">{parseError}</p>
)}
{result && (
<div className="flex flex-col gap-2">
<div
className={`text-sm font-medium ${
result.failed > 0 ? "text-yellow-400" : "text-green-400"
}`}
>
✓ {result.success} {translate("added")}
{result.failed > 0 ? `, ✗ ${result.failed} ${translate("failed")}` : ""}
</div>
{failedItems.length > 0 && (
<ul className="rounded border border-accent/20 bg-sidebar/50 p-2 text-xs font-mono max-h-40 overflow-y-auto">
{failedItems.map((item) => (
<li key={item.index} className="text-red-400">
[{item.index}] {item.error}
</li>
))}
</ul>
)}
</div>
)}
<div className="flex gap-2">
<Button
onClick={handleSubmit}
fullWidth
disabled={submitting || !jsonText.trim()}
>
{submitting ? translate("Importing...") : translate("Import All")}
</Button>
<Button onClick={handleClose} variant="ghost" fullWidth disabled={submitting}>
{translate("Close")}
</Button>
</div>
</div>
</Modal>
);
}
BulkImportCodexModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
onSuccess: PropTypes.func,
};
@@ -17,6 +17,7 @@ import ConnectionRow from "./ConnectionRow";
import AddApiKeyModal from "./AddApiKeyModal";
import EditCompatibleNodeModal from "./EditCompatibleNodeModal";
import AddCustomModelModal from "./AddCustomModelModal";
import BulkImportCodexModal from "./BulkImportCodexModal";
const ONE_BY_ONE_DELAY_MS = 1000;
@@ -36,6 +37,7 @@ export default function ProviderDetailPage() {
const [showIFlowCookieModal, setShowIFlowCookieModal] = useState(false);
const [showAddApiKeyModal, setShowAddApiKeyModal] = useState(false);
const [addConnectionError, setAddConnectionError] = useState("");
const [showBulkImportCodex, setShowBulkImportCodex] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [showEditNodeModal, setShowEditNodeModal] = useState(false);
const [showBulkProxyModal, setShowBulkProxyModal] = useState(false);
@@ -1339,6 +1341,11 @@ export default function ProviderDetailPage() {
Cookie
</Button>
)}
{providerId === "codex" && (
<Button size="sm" icon="playlist_add" variant="secondary" onClick={() => setShowBulkImportCodex(true)}>
{translate("Bulk Add")}
</Button>
)}
<Button
size="sm"
icon="add"
@@ -1383,6 +1390,18 @@ export default function ProviderDetailPage() {
Cookie
</Button>
)}
{providerId === "codex" && (
<Button
size="sm"
icon="playlist_add"
variant="secondary"
onClick={() => setShowBulkImportCodex(true)}
title={translate("Bulk import codex accounts from JSON")}
className="w-full sm:w-auto"
>
{translate("Bulk Add")}
</Button>
)}
{hasDualAuthModes ? (
<>
<Button
@@ -1544,6 +1563,14 @@ export default function ProviderDetailPage() {
/>
)}
{providerId === "codex" && (
<BulkImportCodexModal
isOpen={showBulkImportCodex}
onClose={() => setShowBulkImportCodex(false)}
onSuccess={fetchConnections}
/>
)}
{/* AG Risk Confirmation Modal */}
<ConfirmModal
isOpen={showAgRiskModal}