feat(dashboard): bulk import Grok CLI accounts from JSON
Add a "Bulk Add" flow for the grok-cli provider, mirroring the existing
codex one: paste a JSON array/object or drag-drop multiple .json files,
then create all OAuth connections in a single call.
- BulkImportGrokCliModal: flexible JSON parsing (array, single object,
{accounts:[...]}, concatenated objects) + multi-file upload
- POST /api/oauth/grok-cli/bulk-import: serial createProviderConnection,
snake_case/camelCase token fields, email backfilled from id_token or
access_token, authMethod "device_code" to match the login flow
This commit is contained in:
@@ -0,0 +1,284 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useRef } from "react";
|
||||||
|
import { Modal, Button } from "@/shared/components";
|
||||||
|
import { translate } from "@/i18n/runtime";
|
||||||
|
|
||||||
|
const PLACEHOLDER = `[
|
||||||
|
{
|
||||||
|
"access_token": "eyJ0eXAiOiJhdCtqd3Qi...",
|
||||||
|
"refresh_token": "LZhriF9bf88pPykpXCuZ9...",
|
||||||
|
"id_token": "eyJ0eXAiOiJKV1QiLCJhbGci...",
|
||||||
|
"email": "account1@example.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"access_token": "eyJ0eXAiOiJhdCtqd3Qi...",
|
||||||
|
"refresh_token": "LZhriF9bf88pPykpXCuZ9...",
|
||||||
|
"id_token": "eyJ0eXAiOiJKV1QiLCJhbGci...",
|
||||||
|
"email": "account2@example.com"
|
||||||
|
}
|
||||||
|
]`;
|
||||||
|
|
||||||
|
function parseAccountsInput(rawText) {
|
||||||
|
const trimmed = rawText.trim();
|
||||||
|
if (!trimmed) return [];
|
||||||
|
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(trimmed);
|
||||||
|
} catch (initialErr) {
|
||||||
|
// If direct parse failed, try handling concatenated or comma-separated JSON objects
|
||||||
|
try {
|
||||||
|
let fixed = trimmed;
|
||||||
|
if (!fixed.startsWith("[")) {
|
||||||
|
fixed = fixed.replace(/\}\s*,\s*\{/g, "},{").replace(/\}\s*\{/g, "},{");
|
||||||
|
if (fixed.endsWith(",")) fixed = fixed.slice(0, -1);
|
||||||
|
fixed = `[${fixed}]`;
|
||||||
|
}
|
||||||
|
parsed = JSON.parse(fixed);
|
||||||
|
} catch {
|
||||||
|
throw initialErr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
if (parsed && typeof parsed === "object") {
|
||||||
|
if (Array.isArray(parsed.accounts)) return parsed.accounts;
|
||||||
|
return [parsed];
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Input must be a JSON object or array of objects");
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BulkImportGrokCliModal({ isOpen, onClose, onSuccess }) {
|
||||||
|
const [jsonText, setJsonText] = useState("");
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [parseError, setParseError] = useState("");
|
||||||
|
const [result, setResult] = useState(null);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [fileCountInfo, setFileCountInfo] = useState(null);
|
||||||
|
const fileInputRef = useRef(null);
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
if (submitting) return;
|
||||||
|
setJsonText("");
|
||||||
|
setParseError("");
|
||||||
|
setResult(null);
|
||||||
|
setFileCountInfo(null);
|
||||||
|
setIsDragging(false);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const processFiles = async (files) => {
|
||||||
|
if (!files || files.length === 0) return;
|
||||||
|
setParseError("");
|
||||||
|
const jsonFiles = Array.from(files).filter(
|
||||||
|
(file) => file.name.endsWith(".json") || file.type === "application/json" || file.type === ""
|
||||||
|
);
|
||||||
|
|
||||||
|
if (jsonFiles.length === 0) {
|
||||||
|
setParseError(translate("Please select valid .json files"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const allAccounts = [];
|
||||||
|
for (const file of jsonFiles) {
|
||||||
|
const text = await file.text();
|
||||||
|
const accountsFromFile = parseAccountsInput(text);
|
||||||
|
if (Array.isArray(accountsFromFile)) {
|
||||||
|
allAccounts.push(...accountsFromFile);
|
||||||
|
} else if (accountsFromFile) {
|
||||||
|
allAccounts.push(accountsFromFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allAccounts.length === 0) {
|
||||||
|
setParseError(translate("No accounts found in selected files"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setJsonText(JSON.stringify(allAccounts, null, 2));
|
||||||
|
setFileCountInfo({
|
||||||
|
filesCount: jsonFiles.length,
|
||||||
|
accountsCount: allAccounts.length,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
setParseError(`${translate("Error reading files")}: ${err.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileInputChange = (e) => {
|
||||||
|
processFiles(e.target.files);
|
||||||
|
if (e.target) e.target.value = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragLeave = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(false);
|
||||||
|
if (e.dataTransfer?.files?.length > 0) {
|
||||||
|
processFiles(e.dataTransfer.files);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
setParseError("");
|
||||||
|
setResult(null);
|
||||||
|
|
||||||
|
let accounts;
|
||||||
|
try {
|
||||||
|
accounts = parseAccountsInput(jsonText);
|
||||||
|
} catch (err) {
|
||||||
|
setParseError(`${translate("Invalid JSON")}: ${err.message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!accounts || accounts.length === 0) {
|
||||||
|
setParseError(translate("No accounts found in input"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/oauth/grok-cli/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 Grok CLI Accounts")} onClose={handleClose}>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<p className="text-xs text-text-muted">
|
||||||
|
{translate("Upload multiple .json files or paste JSON array / object.")}
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".json,application/json"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleFileInputChange}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
icon="upload_file"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
{translate("Upload JSON Files")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
className={`relative rounded border transition-colors ${
|
||||||
|
isDragging
|
||||||
|
? "border-primary bg-primary/10 ring-2 ring-primary/30"
|
||||||
|
: "border-accent/30 bg-sidebar"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
className="w-full rounded bg-transparent p-2.5 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);
|
||||||
|
setFileCountInfo(null);
|
||||||
|
}}
|
||||||
|
disabled={submitting}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isDragging && (
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center bg-sidebar/90 rounded pointer-events-none backdrop-blur-xs">
|
||||||
|
<span className="material-symbols-outlined text-3xl text-primary mb-1">upload_file</span>
|
||||||
|
<span className="text-sm font-medium text-primary">
|
||||||
|
{translate("Drop .json files here")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{fileCountInfo && (
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-green-400 font-medium bg-green-500/10 border border-green-500/20 px-2.5 py-1.5 rounded">
|
||||||
|
<span className="material-symbols-outlined text-sm">check_circle</span>
|
||||||
|
<span>
|
||||||
|
{translate("Loaded")} {fileCountInfo.accountsCount} {translate("account(s) from")}{" "}
|
||||||
|
{fileCountInfo.filesCount} {translate("file(s)")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{parseError && (
|
||||||
|
<p className="text-xs text-red-500 break-words">{parseError}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result && result.failed > 0 && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="text-sm font-medium text-yellow-400">
|
||||||
|
✗ {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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ import AddApiKeyModal from "./AddApiKeyModal";
|
|||||||
import EditCompatibleNodeModal from "./EditCompatibleNodeModal";
|
import EditCompatibleNodeModal from "./EditCompatibleNodeModal";
|
||||||
import AddCustomModelModal from "./AddCustomModelModal";
|
import AddCustomModelModal from "./AddCustomModelModal";
|
||||||
import BulkImportCodexModal from "./BulkImportCodexModal";
|
import BulkImportCodexModal from "./BulkImportCodexModal";
|
||||||
|
import BulkImportGrokCliModal from "./BulkImportGrokCliModal";
|
||||||
|
|
||||||
const ONE_BY_ONE_DELAY_MS = 1000;
|
const ONE_BY_ONE_DELAY_MS = 1000;
|
||||||
|
|
||||||
@@ -48,6 +49,7 @@ export default function ProviderDetailPage() {
|
|||||||
const [showAddApiKeyModal, setShowAddApiKeyModal] = useState(false);
|
const [showAddApiKeyModal, setShowAddApiKeyModal] = useState(false);
|
||||||
const [addConnectionError, setAddConnectionError] = useState("");
|
const [addConnectionError, setAddConnectionError] = useState("");
|
||||||
const [showBulkImportCodex, setShowBulkImportCodex] = useState(false);
|
const [showBulkImportCodex, setShowBulkImportCodex] = useState(false);
|
||||||
|
const [showBulkImportGrokCli, setShowBulkImportGrokCli] = 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);
|
||||||
@@ -1529,6 +1531,11 @@ export default function ProviderDetailPage() {
|
|||||||
{translate("Bulk Add")}
|
{translate("Bulk Add")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{providerId === "grok-cli" && (
|
||||||
|
<Button size="sm" icon="playlist_add" variant="secondary" onClick={() => setShowBulkImportGrokCli(true)}>
|
||||||
|
{translate("Bulk Add")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
icon="add"
|
icon="add"
|
||||||
@@ -1598,6 +1605,18 @@ export default function ProviderDetailPage() {
|
|||||||
{translate("Bulk Add")}
|
{translate("Bulk Add")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{providerId === "grok-cli" && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
icon="playlist_add"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => setShowBulkImportGrokCli(true)}
|
||||||
|
title={translate("Bulk import Grok CLI accounts from JSON")}
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
{translate("Bulk Add")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{hasDualAuthModes ? (
|
{hasDualAuthModes ? (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
@@ -1778,6 +1797,14 @@ export default function ProviderDetailPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{providerId === "grok-cli" && (
|
||||||
|
<BulkImportGrokCliModal
|
||||||
|
isOpen={showBulkImportGrokCli}
|
||||||
|
onClose={() => setShowBulkImportGrokCli(false)}
|
||||||
|
onSuccess={fetchConnections}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* AG Risk Confirmation Modal */}
|
{/* AG Risk Confirmation Modal */}
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
isOpen={showAgRiskModal}
|
isOpen={showAgRiskModal}
|
||||||
|
|||||||
116
src/app/api/oauth/grok-cli/bulk-import/route.js
Normal file
116
src/app/api/oauth/grok-cli/bulk-import/route.js
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { createProviderConnection } from "@/models";
|
||||||
|
import { decodeXaiIdTokenEmail, extractEmailFromAccessToken } from "@/lib/oauth/providerHelpers";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/oauth/grok-cli/bulk-import
|
||||||
|
* Bulk import multiple Grok CLI (OAuth/Device) account JSON objects in one call.
|
||||||
|
*
|
||||||
|
* Body accepts any of:
|
||||||
|
* - Array: [{...}, {...}]
|
||||||
|
* - Single: {...}
|
||||||
|
* - Wrapped: { accounts: [{...}, ...] }
|
||||||
|
*
|
||||||
|
* Each item accepts snake_case or camelCase:
|
||||||
|
* access_token / accessToken
|
||||||
|
* refresh_token / refreshToken
|
||||||
|
* id_token / idToken
|
||||||
|
* email
|
||||||
|
* expires_in / expiresIn / expires_at / expiresAt
|
||||||
|
*/
|
||||||
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessToken = raw.access_token || raw.accessToken;
|
||||||
|
const refreshToken = raw.refresh_token || raw.refreshToken || null;
|
||||||
|
const idToken = raw.id_token || raw.idToken || null;
|
||||||
|
let email = raw.email || null;
|
||||||
|
|
||||||
|
if (!accessToken || typeof accessToken !== "string") {
|
||||||
|
throw new Error("Missing access_token / accessToken");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
email =
|
||||||
|
decodeXaiIdTokenEmail(idToken) ||
|
||||||
|
extractEmailFromAccessToken(accessToken) ||
|
||||||
|
null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let expiresAt = raw.expires_at || raw.expiresAt || null;
|
||||||
|
const expiresIn = raw.expires_in || raw.expiresIn;
|
||||||
|
if (!expiresAt && typeof expiresIn === "number" && expiresIn > 0) {
|
||||||
|
expiresAt = new Date(Date.now() + expiresIn * 1000).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const psd = {
|
||||||
|
authMethod: "device_code",
|
||||||
|
...(idToken ? { idToken } : {}),
|
||||||
|
...(email ? { email } : {}),
|
||||||
|
...(raw.providerSpecificData || {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const created = await createProviderConnection({
|
||||||
|
provider: "grok-cli",
|
||||||
|
authType: "oauth",
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
expiresAt,
|
||||||
|
email,
|
||||||
|
displayName: raw.displayName || raw.name || undefined,
|
||||||
|
providerSpecificData: psd,
|
||||||
|
testStatus: "active",
|
||||||
|
});
|
||||||
|
|
||||||
|
success++;
|
||||||
|
results.push({ index: i, ok: true, id: created.id, email: created.email });
|
||||||
|
} catch (err) {
|
||||||
|
failed++;
|
||||||
|
results.push({ index: i, ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
total: accounts.length,
|
||||||
|
success,
|
||||||
|
failed,
|
||||||
|
results,
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user