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:
ariesho2903
2026-08-27 15:49:35 +07:00
parent 699edac327
commit abb20d9f39
3 changed files with 427 additions and 0 deletions

View File

@@ -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>
);
}

View File

@@ -22,6 +22,7 @@ import AddApiKeyModal from "./AddApiKeyModal";
import EditCompatibleNodeModal from "./EditCompatibleNodeModal";
import AddCustomModelModal from "./AddCustomModelModal";
import BulkImportCodexModal from "./BulkImportCodexModal";
import BulkImportGrokCliModal from "./BulkImportGrokCliModal";
const ONE_BY_ONE_DELAY_MS = 1000;
@@ -48,6 +49,7 @@ export default function ProviderDetailPage() {
const [showAddApiKeyModal, setShowAddApiKeyModal] = useState(false);
const [addConnectionError, setAddConnectionError] = useState("");
const [showBulkImportCodex, setShowBulkImportCodex] = useState(false);
const [showBulkImportGrokCli, setShowBulkImportGrokCli] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [showEditNodeModal, setShowEditNodeModal] = useState(false);
const [showBulkProxyModal, setShowBulkProxyModal] = useState(false);
@@ -1529,6 +1531,11 @@ export default function ProviderDetailPage() {
{translate("Bulk Add")}
</Button>
)}
{providerId === "grok-cli" && (
<Button size="sm" icon="playlist_add" variant="secondary" onClick={() => setShowBulkImportGrokCli(true)}>
{translate("Bulk Add")}
</Button>
)}
<Button
size="sm"
icon="add"
@@ -1598,6 +1605,18 @@ export default function ProviderDetailPage() {
{translate("Bulk Add")}
</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 ? (
<>
<Button
@@ -1778,6 +1797,14 @@ export default function ProviderDetailPage() {
/>
)}
{providerId === "grok-cli" && (
<BulkImportGrokCliModal
isOpen={showBulkImportGrokCli}
onClose={() => setShowBulkImportGrokCli(false)}
onSuccess={fetchConnections}
/>
)}
{/* AG Risk Confirmation Modal */}
<ConfirmModal
isOpen={showAgRiskModal}

View 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,
});
}