From fc9f8d91f7f682b0fabcf1f4216a882c44c336cd Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Sun, 17 May 2026 11:30:49 -0300 Subject: [PATCH] feat(providers): bulk add API keys with Single/Bulk tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the 9router UX (one textarea, name|apiKey per line) but goes further: dedicated server-side endpoint with Zod validation, partial- failure semantics, audit log, and provider whitelist. UI (src/app/(dashboard)/dashboard/providers/[id]/page.tsx): - AddApiKeyModal now switches between Single (existing behaviour) and Bulk Add via tab strip. Tabs hide for providers that don't support bulk (Vertex, web-session, OAuth, multi-field). - Bulk pane: textarea, shared Priority + "validate each key" checkbox, result panel with per-line errors (truncated at 10). Backend: - POST /api/providers/bulk: iterates entries through createProviderConnection with the same provider-specific normalization as the single endpoint. Returns {success, failed, total, created, errors[]}. Optional pre-save validation via /api/providers/validate when validateKeys=true. Each entry succeeds/fails independently — no transaction rollback. - Bulk audit event logged once per request plus per-entry success events. Schemas: - bulkCreateProviderSchema (src/shared/validation/schemas.ts): max 200 entries, mandatory name+apiKey per entry, google-pse-search cx guard. - supportsBulkApiKey() helper (src/shared/constants/providers.ts) with explicit deny-list for OAuth/web-session/multi-field providers. Parser: - parseBulkApiKeys() (src/shared/utils/bulkApiKeyParser.ts) handles CRLF, # comments, blank lines, pipe inside apiKey, empty-name fallback, and caps input at BULK_API_KEY_MAX_LINES (200) with a warning. Tests: - tests/unit/bulkApiKeyParser.test.ts: 12 cases (format, edge cases, cap) - tests/unit/providers-bulk-route.test.ts: 12 cases (schema, whitelist, response shape, apiKey leak guard) i18n: - en.json: bulkTabSingle, bulkTabBulkAdd, bulkAddFormatHint, bulkValidateKeys, bulkAddAllKeys, bulkAddedCount, bulkFailedCount, adding --- .../dashboard/providers/[id]/page.tsx | 731 +++++++++++------- src/app/api/providers/bulk/route.ts | 209 +++++ src/i18n/messages/en.json | 8 + src/shared/constants/providers.ts | 29 + src/shared/utils/bulkApiKeyParser.ts | 67 ++ src/shared/validation/schemas.ts | 38 + tests/unit/bulkApiKeyParser.test.ts | 89 +++ tests/unit/providers-bulk-route.test.ts | 129 ++++ 8 files changed, 1023 insertions(+), 277 deletions(-) create mode 100644 src/app/api/providers/bulk/route.ts create mode 100644 src/shared/utils/bulkApiKeyParser.ts create mode 100644 tests/unit/bulkApiKeyParser.test.ts create mode 100644 tests/unit/providers-bulk-route.test.ts diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx index 58651ada52..587cfb1b93 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx @@ -31,7 +31,9 @@ import { isSelfHostedChatProvider, providerAllowsOptionalApiKey, supportsApiKeyOnFreeProvider, + supportsBulkApiKey, } from "@/shared/constants/providers"; +import { parseBulkApiKeys } from "@/shared/utils/bulkApiKeyParser"; import { getModelsByProviderId } from "@/shared/constants/models"; import { compatibleProviderSupportsModelImport, @@ -6294,6 +6296,18 @@ function AddApiKeyModal({ const [saveError, setSaveError] = useState(null); const [showAdvanced, setShowAdvanced] = useState(false); const [copiedCommandCodeField, setCopiedCommandCodeField] = useState(null); + + const bulkSupported = supportsBulkApiKey(provider); + const [mode, setMode] = useState<"single" | "bulk">("single"); + const [bulkText, setBulkText] = useState(""); + const [bulkValidateKeys, setBulkValidateKeys] = useState(false); + const [bulkResult, setBulkResult] = useState<{ + success: number; + failed: number; + total: number; + errors: Array<{ index: number; name: string; message: string }>; + } | null>(null); + const [bulkWarnings, setBulkWarnings] = useState([]); const apiCredentialLabel = isQoder ? t("personalAccessTokenLabel") : isWebSessionProvider @@ -6486,6 +6500,45 @@ function AddApiKeyModal({ } }; + const handleBulkSubmit = async () => { + if (!provider) return; + const parsed = parseBulkApiKeys(bulkText); + setBulkWarnings(parsed.warnings); + if (parsed.entries.length === 0) return; + + setSaving(true); + setBulkResult(null); + setSaveError(null); + + try { + const res = await fetch("/api/providers/bulk", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + provider, + entries: parsed.entries.map((e) => ({ name: e.name, apiKey: e.apiKey })), + priority: formData.priority || 1, + validateKeys: bulkValidateKeys, + }), + }); + const data = await res.json(); + if (!res.ok) { + setSaveError(typeof data?.error === "string" ? data.error : t("failedSaveConnection")); + return; + } + setBulkResult({ + success: data.success || 0, + failed: data.failed || 0, + total: data.total || 0, + errors: Array.isArray(data.errors) ? data.errors : [], + }); + } catch (err) { + setSaveError(err instanceof Error ? err.message : t("failedSaveConnection")); + } finally { + setSaving(false); + } + }; + if (!provider) return null; return ( @@ -6495,301 +6548,425 @@ function AddApiKeyModal({ onClose={onClose} >
- {isCcCompatible && ( -
-
- - warning - -

{t("ccCompatibleValidationHint")}

-
+ {bulkSupported && ( +
+ +
)} - {isCommandCode && onStartCommandCodeAuth && ( -
-
- - open_in_new - -
-

Browser/manual connect

-

- Open Command Code Studio, then paste the returned key/JSON/URL into the API key - field below. -

- {commandCodeAuthState?.message && ( -

- {commandCodeAuthPhaseLabel}: {commandCodeAuthState.message} -

+ + {bulkSupported && mode === "bulk" && ( +
+

{t("bulkAddFormatHint")}

+