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:
@@ -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 AddApiKeyModal from "./AddApiKeyModal";
|
||||||
import EditCompatibleNodeModal from "./EditCompatibleNodeModal";
|
import EditCompatibleNodeModal from "./EditCompatibleNodeModal";
|
||||||
import AddCustomModelModal from "./AddCustomModelModal";
|
import AddCustomModelModal from "./AddCustomModelModal";
|
||||||
|
import BulkImportCodexModal from "./BulkImportCodexModal";
|
||||||
|
|
||||||
const ONE_BY_ONE_DELAY_MS = 1000;
|
const ONE_BY_ONE_DELAY_MS = 1000;
|
||||||
|
|
||||||
@@ -36,6 +37,7 @@ export default function ProviderDetailPage() {
|
|||||||
const [showIFlowCookieModal, setShowIFlowCookieModal] = useState(false);
|
const [showIFlowCookieModal, setShowIFlowCookieModal] = useState(false);
|
||||||
const [showAddApiKeyModal, setShowAddApiKeyModal] = useState(false);
|
const [showAddApiKeyModal, setShowAddApiKeyModal] = useState(false);
|
||||||
const [addConnectionError, setAddConnectionError] = useState("");
|
const [addConnectionError, setAddConnectionError] = useState("");
|
||||||
|
const [showBulkImportCodex, setShowBulkImportCodex] = useState(false);
|
||||||
const [showEditModal, setShowEditModal] = useState(false);
|
const [showEditModal, setShowEditModal] = useState(false);
|
||||||
const [showEditNodeModal, setShowEditNodeModal] = useState(false);
|
const [showEditNodeModal, setShowEditNodeModal] = useState(false);
|
||||||
const [showBulkProxyModal, setShowBulkProxyModal] = useState(false);
|
const [showBulkProxyModal, setShowBulkProxyModal] = useState(false);
|
||||||
@@ -1339,6 +1341,11 @@ export default function ProviderDetailPage() {
|
|||||||
Cookie
|
Cookie
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{providerId === "codex" && (
|
||||||
|
<Button size="sm" icon="playlist_add" variant="secondary" onClick={() => setShowBulkImportCodex(true)}>
|
||||||
|
{translate("Bulk Add")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
icon="add"
|
icon="add"
|
||||||
@@ -1383,6 +1390,18 @@ export default function ProviderDetailPage() {
|
|||||||
Cookie
|
Cookie
|
||||||
</Button>
|
</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 ? (
|
{hasDualAuthModes ? (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
@@ -1544,6 +1563,14 @@ export default function ProviderDetailPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{providerId === "codex" && (
|
||||||
|
<BulkImportCodexModal
|
||||||
|
isOpen={showBulkImportCodex}
|
||||||
|
onClose={() => setShowBulkImportCodex(false)}
|
||||||
|
onSuccess={fetchConnections}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* AG Risk Confirmation Modal */}
|
{/* AG Risk Confirmation Modal */}
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
isOpen={showAgRiskModal}
|
isOpen={showAgRiskModal}
|
||||||
|
|||||||
121
src/app/api/oauth/codex/bulk-import/route.js
Normal file
121
src/app/api/oauth/codex/bulk-import/route.js
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { createProviderConnection } from "@/models";
|
||||||
|
import { extractCodexAccountInfo } from "@/lib/oauth/providers";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/oauth/codex/bulk-import
|
||||||
|
* Bulk import multiple codex (OAuth) account JSON objects in one call.
|
||||||
|
*
|
||||||
|
* Body accepts any of:
|
||||||
|
* - Array: [{...}, {...}]
|
||||||
|
* - Single: {...}
|
||||||
|
* - Wrapped: { accounts: [{...}, ...] }
|
||||||
|
*
|
||||||
|
* Each item must contain at least `accessToken`. Missing email / chatgpt
|
||||||
|
* account info is best-effort backfilled from the JWT (idToken or accessToken).
|
||||||
|
*
|
||||||
|
* Tokens are NEVER echoed back in the response.
|
||||||
|
*/
|
||||||
|
export async function POST(request) {
|
||||||
|
let body;
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Invalid JSON body: ${err.message}` },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize to array
|
||||||
|
let accounts;
|
||||||
|
if (Array.isArray(body)) {
|
||||||
|
accounts = body;
|
||||||
|
} else if (body && typeof body === "object" && Array.isArray(body.accounts)) {
|
||||||
|
accounts = body.accounts;
|
||||||
|
} else if (body && typeof body === "object") {
|
||||||
|
accounts = [body];
|
||||||
|
} else {
|
||||||
|
accounts = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(accounts) || accounts.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "No accounts provided" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
let success = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
// SERIAL loop — createProviderConnection reads max(priority) and reorders
|
||||||
|
// inside a transaction. Parallel calls would race on priority assignment.
|
||||||
|
for (let i = 0; i < accounts.length; i++) {
|
||||||
|
const raw = accounts[i];
|
||||||
|
try {
|
||||||
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||||
|
throw new Error("Item is not an object");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip server-controlled fields
|
||||||
|
const {
|
||||||
|
id: _id,
|
||||||
|
provider: _provider,
|
||||||
|
authType: _authType,
|
||||||
|
createdAt: _createdAt,
|
||||||
|
updatedAt: _updatedAt,
|
||||||
|
...item
|
||||||
|
} = raw;
|
||||||
|
|
||||||
|
if (!item.accessToken || typeof item.accessToken !== "string") {
|
||||||
|
throw new Error("Missing accessToken");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backfill missing identity fields from JWT claims
|
||||||
|
const psd = item.providerSpecificData || {};
|
||||||
|
const needsEmail = !item.email;
|
||||||
|
const needsAccountId = !psd.chatgptAccountId;
|
||||||
|
const needsPlanType = !psd.chatgptPlanType;
|
||||||
|
|
||||||
|
if (needsEmail || needsAccountId || needsPlanType) {
|
||||||
|
const info = extractCodexAccountInfo(item.idToken || item.accessToken) || {};
|
||||||
|
if (needsEmail && info.email) item.email = info.email;
|
||||||
|
if (needsAccountId && info.chatgptAccountId) {
|
||||||
|
psd.chatgptAccountId = info.chatgptAccountId;
|
||||||
|
}
|
||||||
|
if (needsPlanType && info.chatgptPlanType) {
|
||||||
|
psd.chatgptPlanType = info.chatgptPlanType;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.keys(psd).length > 0) {
|
||||||
|
item.providerSpecificData = psd;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute expiresAt from expiresIn if absent
|
||||||
|
if (!item.expiresAt && typeof item.expiresIn === "number" && item.expiresIn > 0) {
|
||||||
|
item.expiresAt = new Date(Date.now() + item.expiresIn * 1000).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defaults aligned with OAuth-completed flow
|
||||||
|
if (item.testStatus === undefined) item.testStatus = "active";
|
||||||
|
if (item.isActive === undefined) item.isActive = true;
|
||||||
|
if (!item.lastRefreshAt) item.lastRefreshAt = new Date().toISOString();
|
||||||
|
|
||||||
|
const created = await createProviderConnection({
|
||||||
|
provider: "codex",
|
||||||
|
authType: "oauth",
|
||||||
|
...item,
|
||||||
|
});
|
||||||
|
|
||||||
|
results.push({ index: i, ok: true, id: created.id });
|
||||||
|
success++;
|
||||||
|
} catch (e) {
|
||||||
|
results.push({ index: i, ok: false, error: e.message || "Unknown error" });
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success, failed, results });
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ const OPTIONAL_FIELDS = [
|
|||||||
"accessToken", "refreshToken", "expiresAt", "tokenType",
|
"accessToken", "refreshToken", "expiresAt", "tokenType",
|
||||||
"scope", "projectId", "apiKey", "testStatus",
|
"scope", "projectId", "apiKey", "testStatus",
|
||||||
"lastTested", "lastError", "lastErrorAt", "rateLimitedUntil", "expiresIn", "errorCode",
|
"lastTested", "lastError", "lastErrorAt", "rateLimitedUntil", "expiresIn", "errorCode",
|
||||||
"consecutiveUseCount",
|
"consecutiveUseCount", "idToken", "lastRefreshAt",
|
||||||
];
|
];
|
||||||
|
|
||||||
function rowToConn(row) {
|
function rowToConn(row) {
|
||||||
|
|||||||
Reference in New Issue
Block a user