mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
Merge pull request #2343 from diegosouzapw/feat/codex-auth-import-bulk
feat(codex): bulk import Codex auth.json — multi-file, paste, ZIP
This commit is contained in:
7
package-lock.json
generated
7
package-lock.json
generated
@@ -28,6 +28,7 @@
|
||||
"csv-stringify": "^6.7.0",
|
||||
"express": "^5.2.1",
|
||||
"fetch-socks": "^1.3.3",
|
||||
"fflate": "^0.8.3",
|
||||
"fuse.js": "^7.3.0",
|
||||
"gray-matter": "^4.0.3",
|
||||
"http-proxy-middleware": "^4.0.0",
|
||||
@@ -9177,6 +9178,12 @@
|
||||
"undici": ">=7"
|
||||
}
|
||||
},
|
||||
"node_modules/fflate": {
|
||||
"version": "0.8.3",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
|
||||
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/file-entry-cache": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
||||
|
||||
@@ -145,6 +145,7 @@
|
||||
"csv-stringify": "^6.7.0",
|
||||
"express": "^5.2.1",
|
||||
"fetch-socks": "^1.3.3",
|
||||
"fflate": "^0.8.3",
|
||||
"fuse.js": "^7.3.0",
|
||||
"gray-matter": "^4.0.3",
|
||||
"http-proxy-middleware": "^4.0.0",
|
||||
|
||||
@@ -3815,7 +3815,7 @@ export default function ProviderDetailPage() {
|
||||
{/* Codex Import Auth Modal */}
|
||||
{providerId === "codex" && importCodexModalOpen && (
|
||||
<ImportCodexAuthModal
|
||||
key={importCodexModalOpen ? "open" : "closed"}
|
||||
key="import-codex-modal"
|
||||
onClose={() => setImportCodexModalOpen(false)}
|
||||
onSuccess={() => {
|
||||
setImportCodexModalOpen(false);
|
||||
@@ -7008,135 +7008,313 @@ function AddApiKeyModal({
|
||||
);
|
||||
}
|
||||
|
||||
// ──── ImportCodexAuthModal ────────────────────────────────────────────────────
|
||||
|
||||
interface ImportCodexAuthModalProps {
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
type ImportTopTab = "single" | "bulk";
|
||||
type BulkSubMode = "upload" | "paste" | "zip";
|
||||
|
||||
interface BulkEntry {
|
||||
name: string;
|
||||
json: unknown;
|
||||
parseError: string | null;
|
||||
email: string | null;
|
||||
}
|
||||
|
||||
function extractEmailFromJwtLocal(idToken: string): string | null {
|
||||
try {
|
||||
const parts = idToken.split(".");
|
||||
if (parts.length !== 3) return null;
|
||||
const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
|
||||
return typeof payload.email === "string" ? payload.email : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function previewCodexJson(json: unknown): { valid: boolean; email: string | null } {
|
||||
try {
|
||||
const doc = json && typeof json === "object" ? (json as Record<string, unknown>) : null;
|
||||
if (!doc || doc.auth_mode !== "chatgpt") return { valid: false, email: null };
|
||||
const tokens =
|
||||
doc.tokens && typeof doc.tokens === "object" ? (doc.tokens as Record<string, unknown>) : null;
|
||||
if (!tokens?.id_token || typeof tokens.id_token !== "string")
|
||||
return { valid: false, email: null };
|
||||
return { valid: true, email: extractEmailFromJwtLocal(tokens.id_token as string) };
|
||||
} catch {
|
||||
return { valid: false, email: null };
|
||||
}
|
||||
}
|
||||
|
||||
function parseBulkPasteText(text: string): BulkEntry[] {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return [];
|
||||
|
||||
const tryParse = (s: string): BulkEntry => {
|
||||
try {
|
||||
const json = JSON.parse(s);
|
||||
const { email } = previewCodexJson(json);
|
||||
return { name: email || "unknown", json, parseError: null, email };
|
||||
} catch {
|
||||
return { name: "parse error", json: null, parseError: "Invalid JSON", email: null };
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const arr = JSON.parse(trimmed);
|
||||
if (Array.isArray(arr))
|
||||
return arr.map((item) => {
|
||||
const { email } = previewCodexJson(item);
|
||||
return { name: email || "unknown", json: item, parseError: null, email };
|
||||
});
|
||||
const { email } = previewCodexJson(arr);
|
||||
return [{ name: email || "unknown", json: arr, parseError: null, email }];
|
||||
} catch {
|
||||
return trimmed
|
||||
.split(/^---$/m)
|
||||
.map((s) => tryParse(s.trim()))
|
||||
.filter((e) => e.json !== null || e.parseError !== null);
|
||||
}
|
||||
}
|
||||
|
||||
function ImportCodexAuthModal({ onClose, onSuccess }: ImportCodexAuthModalProps) {
|
||||
const t = useTranslations("providers");
|
||||
const notify = useNotificationStore();
|
||||
const [tab, setTab] = useState<"upload" | "paste">("upload");
|
||||
const [pasteText, setPasteText] = useState("");
|
||||
const [parsedJson, setParsedJson] = useState<unknown>(null);
|
||||
const [parseError, setParseError] = useState<string | null>(null);
|
||||
const [detectedEmail, setDetectedEmail] = useState<string | null>(null);
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [overwriteExisting, setOverwriteExisting] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [resultError, setResultError] = useState<string | null>(null);
|
||||
|
||||
function extractEmailFromJwt(idToken: string): string | null {
|
||||
try {
|
||||
const parts = idToken.split(".");
|
||||
if (parts.length !== 3) return null;
|
||||
const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
|
||||
return typeof payload.email === "string" ? payload.email : null;
|
||||
} catch {
|
||||
return null;
|
||||
// Top-level tab: Single / Bulk
|
||||
const [topTab, setTopTab] = useState<ImportTopTab>("single");
|
||||
|
||||
// ── Single mode state ──
|
||||
const [singleTab, setSingleTab] = useState<"upload" | "paste">("upload");
|
||||
const [singleParsedJson, setSingleParsedJson] = useState<unknown>(null);
|
||||
const [singleParseError, setSingleParseError] = useState<string | null>(null);
|
||||
const [singleDetectedEmail, setSingleDetectedEmail] = useState<string | null>(null);
|
||||
const [singlePasteText, setSinglePasteText] = useState("");
|
||||
const [singleName, setSingleName] = useState("");
|
||||
const [singleEmail, setSingleEmail] = useState("");
|
||||
const [singleOverwrite, setSingleOverwrite] = useState(false);
|
||||
const [singleLoading, setSingleLoading] = useState(false);
|
||||
const [singleError, setSingleError] = useState<string | null>(null);
|
||||
|
||||
// ── Bulk mode state ──
|
||||
const [bulkMode, setBulkMode] = useState<BulkSubMode>("upload");
|
||||
const [bulkEntries, setBulkEntries] = useState<BulkEntry[]>([]);
|
||||
const [bulkPasteText, setBulkPasteText] = useState("");
|
||||
const [bulkZipExtracting, setBulkZipExtracting] = useState(false);
|
||||
const [bulkZipError, setBulkZipError] = useState<string | null>(null);
|
||||
const [bulkOverwrite, setBulkOverwrite] = useState(false);
|
||||
const [bulkLoading, setBulkLoading] = useState(false);
|
||||
const [bulkResult, setBulkResult] = useState<{
|
||||
success: number;
|
||||
failed: number;
|
||||
errors: { index: number; name: string; message: string }[];
|
||||
} | null>(null);
|
||||
|
||||
// ── Single helpers ──
|
||||
|
||||
function handleSinglePreview(json: unknown) {
|
||||
setSingleParseError(null);
|
||||
setSingleDetectedEmail(null);
|
||||
setSingleParsedJson(null);
|
||||
const { valid, email } = previewCodexJson(json);
|
||||
if (!valid) {
|
||||
setSingleParseError(t("codexImportInvalidShape") || "Not a valid Codex auth.json");
|
||||
return;
|
||||
}
|
||||
setSingleDetectedEmail(email);
|
||||
if (email && !singleEmail) setSingleEmail(email);
|
||||
setSingleParsedJson(json);
|
||||
}
|
||||
|
||||
function tryParseAndPreview(json: unknown) {
|
||||
setParseError(null);
|
||||
setDetectedEmail(null);
|
||||
setParsedJson(null);
|
||||
try {
|
||||
const doc = json && typeof json === "object" ? (json as Record<string, unknown>) : null;
|
||||
if (!doc || doc.auth_mode !== "chatgpt") {
|
||||
setParseError(t("codexImportInvalidShape") || "Not a valid Codex auth.json");
|
||||
return;
|
||||
}
|
||||
const tokens =
|
||||
doc.tokens && typeof doc.tokens === "object"
|
||||
? (doc.tokens as Record<string, unknown>)
|
||||
: null;
|
||||
if (!tokens?.id_token || typeof tokens.id_token !== "string") {
|
||||
setParseError(t("codexImportInvalidShape") || "Not a valid Codex auth.json");
|
||||
return;
|
||||
}
|
||||
const detected = extractEmailFromJwt(tokens.id_token as string);
|
||||
setDetectedEmail(detected);
|
||||
if (detected && !email) setEmail(detected);
|
||||
setParsedJson(doc);
|
||||
} catch {
|
||||
setParseError(t("codexImportInvalidJson") || "Could not parse JSON");
|
||||
}
|
||||
}
|
||||
|
||||
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
function handleSingleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (ev) => {
|
||||
const text = ev.target?.result as string;
|
||||
try {
|
||||
const json = JSON.parse(text);
|
||||
tryParseAndPreview(json);
|
||||
setParsedJson(json);
|
||||
handleSinglePreview(JSON.parse(ev.target?.result as string));
|
||||
} catch {
|
||||
setParseError(t("codexImportInvalidJson") || "Could not parse JSON");
|
||||
setParsedJson(null);
|
||||
setSingleParseError(t("codexImportInvalidJson") || "Could not parse JSON");
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
|
||||
function handlePasteChange(text: string) {
|
||||
setPasteText(text);
|
||||
function handleSinglePasteChange(text: string) {
|
||||
setSinglePasteText(text);
|
||||
if (!text.trim()) {
|
||||
setParsedJson(null);
|
||||
setParseError(null);
|
||||
setDetectedEmail(null);
|
||||
setSingleParsedJson(null);
|
||||
setSingleParseError(null);
|
||||
setSingleDetectedEmail(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const json = JSON.parse(text);
|
||||
tryParseAndPreview(json);
|
||||
handleSinglePreview(JSON.parse(text));
|
||||
} catch {
|
||||
setParseError(t("codexImportInvalidJson") || "Could not parse JSON");
|
||||
setParsedJson(null);
|
||||
setSingleParseError(t("codexImportInvalidJson") || "Could not parse JSON");
|
||||
setSingleParsedJson(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!parsedJson) return;
|
||||
setLoading(true);
|
||||
setResultError(null);
|
||||
async function handleSingleSubmit() {
|
||||
if (!singleParsedJson) return;
|
||||
setSingleLoading(true);
|
||||
setSingleError(null);
|
||||
try {
|
||||
const res = await fetch("/api/providers/codex-auth/import", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
source: { kind: "json", json: parsedJson },
|
||||
name: name.trim() || undefined,
|
||||
email: email.trim() || undefined,
|
||||
overwriteExisting,
|
||||
source: { kind: "json", json: singleParsedJson },
|
||||
name: singleName.trim() || undefined,
|
||||
email: singleEmail.trim() || undefined,
|
||||
overwriteExisting: singleOverwrite,
|
||||
}),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
if (data.code === "duplicate_account") {
|
||||
setResultError(
|
||||
t("codexImportDuplicate") ||
|
||||
"Account already exists — enable Replace existing to overwrite"
|
||||
);
|
||||
} else {
|
||||
setResultError(data.error || t("codexImportFailed") || "Failed to import Codex auth");
|
||||
}
|
||||
setSingleError(
|
||||
data.code === "duplicate_account"
|
||||
? t("codexImportDuplicate") ||
|
||||
"Account already exists — enable Replace existing to overwrite"
|
||||
: data.error || t("codexImportFailed") || "Failed to import"
|
||||
);
|
||||
return;
|
||||
}
|
||||
notify.success(t("codexImportSuccess") || "Codex connection imported successfully");
|
||||
onSuccess();
|
||||
} catch {
|
||||
setResultError(t("codexImportFailed") || "Failed to import Codex auth");
|
||||
setSingleError(t("codexImportFailed") || "Failed to import Codex auth");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setSingleLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const canSubmit = !!parsedJson && !parseError && !loading;
|
||||
// ── Bulk helpers ──
|
||||
|
||||
function handleBulkFilesChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const files = Array.from(e.target.files || []);
|
||||
const entries: BulkEntry[] = [];
|
||||
let pending = files.length;
|
||||
if (pending === 0) return;
|
||||
files.forEach((file) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (ev) => {
|
||||
try {
|
||||
const json = JSON.parse(ev.target?.result as string);
|
||||
const { email } = previewCodexJson(json);
|
||||
entries.push({
|
||||
name: email || file.name.replace(".json", ""),
|
||||
json,
|
||||
parseError: null,
|
||||
email,
|
||||
});
|
||||
} catch {
|
||||
entries.push({ name: file.name, json: null, parseError: "Invalid JSON", email: null });
|
||||
}
|
||||
if (--pending === 0) setBulkEntries([...entries]);
|
||||
};
|
||||
reader.readAsText(file);
|
||||
});
|
||||
}
|
||||
|
||||
function handleBulkPasteChange(text: string) {
|
||||
setBulkPasteText(text);
|
||||
if (!text.trim()) {
|
||||
setBulkEntries([]);
|
||||
return;
|
||||
}
|
||||
setBulkEntries(parseBulkPasteText(text));
|
||||
}
|
||||
|
||||
async function handleZipUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setBulkZipExtracting(true);
|
||||
setBulkZipError(null);
|
||||
setBulkEntries([]);
|
||||
try {
|
||||
const res = await fetch("/api/providers/codex-auth/zip-extract", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/octet-stream" },
|
||||
body: file,
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
setBulkZipError(data.error || t("codexImportBulkZipError") || "Failed to extract ZIP");
|
||||
return;
|
||||
}
|
||||
const extracted: BulkEntry[] = (data.entries || []).map(
|
||||
(entry: { name: string; json: unknown; parseError: string | null }) => {
|
||||
if (entry.parseError)
|
||||
return { name: entry.name, json: null, parseError: entry.parseError, email: null };
|
||||
const { email } = previewCodexJson(entry.json);
|
||||
return {
|
||||
name: email || entry.name.replace(".json", ""),
|
||||
json: entry.json,
|
||||
parseError: null,
|
||||
email,
|
||||
};
|
||||
}
|
||||
);
|
||||
setBulkEntries(extracted);
|
||||
} catch {
|
||||
setBulkZipError(t("codexImportBulkZipError") || "Failed to extract ZIP");
|
||||
} finally {
|
||||
setBulkZipExtracting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBulkSubmit() {
|
||||
const validEntries = bulkEntries.filter((e) => !e.parseError && e.json !== null);
|
||||
if (validEntries.length === 0) return;
|
||||
setBulkLoading(true);
|
||||
setBulkResult(null);
|
||||
try {
|
||||
const res = await fetch("/api/providers/codex-auth/import-bulk", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
entries: validEntries.map((e) => ({
|
||||
json: e.json,
|
||||
name: e.name || undefined,
|
||||
email: e.email || undefined,
|
||||
})),
|
||||
overwriteExisting: bulkOverwrite,
|
||||
}),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
notify.error(data.error || t("codexImportFailed") || "Failed to import");
|
||||
return;
|
||||
}
|
||||
setBulkResult({ success: data.success, failed: data.failed, errors: data.errors || [] });
|
||||
if (data.success > 0) onSuccess();
|
||||
} catch {
|
||||
notify.error(t("codexImportFailed") || "Failed to import Codex auth");
|
||||
} finally {
|
||||
setBulkLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const singleCanSubmit = !!singleParsedJson && !singleParseError && !singleLoading;
|
||||
const validBulkCount = bulkEntries.filter((e) => !e.parseError && e.json !== null).length;
|
||||
const bulkCanSubmit = validBulkCount > 0 && !bulkLoading && !bulkZipExtracting;
|
||||
|
||||
const TOP_TABS: { id: ImportTopTab; label: string }[] = [
|
||||
{ id: "single", label: t("codexImportTabSingle") || "Single" },
|
||||
{ id: "bulk", label: t("codexImportTabBulk") || "Bulk" },
|
||||
];
|
||||
|
||||
const BULK_MODES: { id: BulkSubMode; label: string }[] = [
|
||||
{ id: "upload", label: t("codexImportBulkModeUpload") || "Upload files" },
|
||||
{ id: "paste", label: t("codexImportBulkModePaste") || "Paste list" },
|
||||
{ id: "zip", label: t("codexImportBulkModeZip") || "ZIP archive" },
|
||||
];
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -7146,136 +7324,322 @@ function ImportCodexAuthModal({ onClose, onSuccess }: ImportCodexAuthModalProps)
|
||||
maxWidth="max-w-lg"
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Tabs */}
|
||||
{/* Top-level Single / Bulk tabs */}
|
||||
<div className="flex border-b border-border">
|
||||
{(["upload", "paste"] as const).map((id) => (
|
||||
{TOP_TABS.map(({ id, label }) => (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => {
|
||||
setTab(id);
|
||||
setParsedJson(null);
|
||||
setParseError(null);
|
||||
setDetectedEmail(null);
|
||||
setTopTab(id);
|
||||
setBulkResult(null);
|
||||
setSingleError(null);
|
||||
}}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
tab === id
|
||||
topTab === id
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-text-muted hover:text-text-main"
|
||||
}`}
|
||||
>
|
||||
{id === "upload"
|
||||
? t("codexImportTabUpload") || "Upload file"
|
||||
: t("codexImportTabPaste") || "Paste JSON"}
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Upload tab */}
|
||||
{tab === "upload" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-text-main">
|
||||
{t("codexImportFileLabel") || "Choose auth.json"}
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
accept=".json"
|
||||
onChange={handleFileChange}
|
||||
className="text-sm text-text-muted file:mr-3 file:py-1.5 file:px-3 file:rounded-lg file:border file:border-border file:text-xs file:bg-bg-subtle file:text-text-main hover:file:bg-bg-hover cursor-pointer"
|
||||
/>
|
||||
<p className="text-xs text-text-muted">
|
||||
{t("codexImportFileHint") ||
|
||||
"Select the auth.json file exported from Codex or OmniRoute."}
|
||||
</p>
|
||||
</div>
|
||||
{/* ── Single tab ── */}
|
||||
{topTab === "single" && (
|
||||
<>
|
||||
{/* Source sub-tabs */}
|
||||
<div className="flex border-b border-border">
|
||||
{(["upload", "paste"] as const).map((id) => (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => {
|
||||
setSingleTab(id);
|
||||
setSingleParsedJson(null);
|
||||
setSingleParseError(null);
|
||||
setSingleDetectedEmail(null);
|
||||
}}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
singleTab === id
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-text-muted hover:text-text-main"
|
||||
}`}
|
||||
>
|
||||
{id === "upload"
|
||||
? t("codexImportTabUpload") || "Upload file"
|
||||
: t("codexImportTabPaste") || "Paste JSON"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{singleTab === "upload" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-text-main">
|
||||
{t("codexImportFileLabel") || "Choose auth.json"}
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
accept=".json"
|
||||
onChange={handleSingleFileChange}
|
||||
className="text-sm text-text-muted file:mr-3 file:py-1.5 file:px-3 file:rounded-lg file:border file:border-border file:text-xs file:bg-bg-subtle file:text-text-main hover:file:bg-bg-hover cursor-pointer"
|
||||
/>
|
||||
<p className="text-xs text-text-muted">
|
||||
{t("codexImportFileHint") ||
|
||||
"Select the auth.json file exported from Codex or OmniRoute."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{singleTab === "paste" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-text-main">
|
||||
{t("codexImportPasteLabel") || "Paste the JSON content"}
|
||||
</label>
|
||||
<textarea
|
||||
value={singlePasteText}
|
||||
onChange={(e) => handleSinglePasteChange(e.target.value)}
|
||||
rows={7}
|
||||
placeholder='{ "auth_mode": "chatgpt", ... }'
|
||||
className="w-full rounded-lg border border-border bg-bg-subtle px-3 py-2 text-xs font-mono text-text-main placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/40 resize-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{singleParseError && <p className="text-sm text-red-500">{singleParseError}</p>}
|
||||
{singleDetectedEmail && !singleParseError && (
|
||||
<p className="text-xs text-text-muted">
|
||||
{t("codexImportDetectedEmail", { email: singleDetectedEmail }) ||
|
||||
`Detected: ${singleDetectedEmail}`}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-sm font-medium text-text-main">
|
||||
{t("codexImportEmailLabel") || "Account email"}
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={singleEmail}
|
||||
onChange={(e) => setSingleEmail(e.target.value)}
|
||||
placeholder="user@example.com"
|
||||
className="rounded-lg border border-border bg-bg-subtle px-3 py-2 text-sm text-text-main placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/40"
|
||||
/>
|
||||
<p className="text-xs text-text-muted">
|
||||
{t("codexImportEmailHint") || "Auto-detected from the file; edit if needed."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-sm font-medium text-text-main">
|
||||
{t("codexImportNameLabel") || "Connection name (optional)"}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={singleName}
|
||||
onChange={(e) => setSingleName(e.target.value)}
|
||||
placeholder={singleEmail || "Codex (imported)"}
|
||||
className="rounded-lg border border-border bg-bg-subtle px-3 py-2 text-sm text-text-main placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/40"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={singleOverwrite}
|
||||
onChange={(e) => setSingleOverwrite(e.target.checked)}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
<span className="text-sm text-text-main">
|
||||
{t("codexImportOverwriteLabel") ||
|
||||
"Replace existing connection if account already exists"}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{singleError && (
|
||||
<div className="rounded-lg bg-red-500/10 border border-red-500/20 px-4 py-3 text-sm text-red-400">
|
||||
{singleError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Button
|
||||
onClick={handleSingleSubmit}
|
||||
disabled={!singleCanSubmit}
|
||||
loading={singleLoading}
|
||||
fullWidth
|
||||
>
|
||||
{t("codexImportSubmit") || "Import"}
|
||||
</Button>
|
||||
<Button onClick={onClose} variant="ghost" fullWidth>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Paste tab */}
|
||||
{tab === "paste" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-text-main">
|
||||
{t("codexImportPasteLabel") || "Paste the JSON content"}
|
||||
{/* ── Bulk tab ── */}
|
||||
{topTab === "bulk" && (
|
||||
<>
|
||||
{/* Sub-mode selector */}
|
||||
<div className="flex gap-1 p-1 bg-bg-subtle rounded-lg">
|
||||
{BULK_MODES.map(({ id, label }) => (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => {
|
||||
setBulkMode(id);
|
||||
setBulkEntries([]);
|
||||
setBulkZipError(null);
|
||||
setBulkPasteText("");
|
||||
setBulkResult(null);
|
||||
}}
|
||||
className={`flex-1 px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${
|
||||
bulkMode === id
|
||||
? "bg-bg-primary text-text-main shadow-sm"
|
||||
: "text-text-muted hover:text-text-main"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Upload mode */}
|
||||
{bulkMode === "upload" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<input
|
||||
type="file"
|
||||
accept=".json"
|
||||
multiple
|
||||
onChange={handleBulkFilesChange}
|
||||
className="text-sm text-text-muted file:mr-3 file:py-1.5 file:px-3 file:rounded-lg file:border file:border-border file:text-xs file:bg-bg-subtle file:text-text-main hover:file:bg-bg-hover cursor-pointer"
|
||||
/>
|
||||
<p className="text-xs text-text-muted">
|
||||
{t("codexImportBulkUploadHint") || "Select multiple .json files"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Paste mode */}
|
||||
{bulkMode === "paste" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<textarea
|
||||
value={bulkPasteText}
|
||||
onChange={(e) => handleBulkPasteChange(e.target.value)}
|
||||
rows={7}
|
||||
placeholder={'[{ "auth_mode": "chatgpt", ... }, ...]'}
|
||||
className="w-full rounded-lg border border-border bg-bg-subtle px-3 py-2 text-xs font-mono text-text-main placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/40 resize-none"
|
||||
/>
|
||||
<p className="text-xs text-text-muted">
|
||||
{t("codexImportBulkPasteHint") || "JSON array or multiple JSONs separated by ---"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ZIP mode */}
|
||||
{bulkMode === "zip" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<input
|
||||
type="file"
|
||||
accept=".zip"
|
||||
onChange={handleZipUpload}
|
||||
disabled={bulkZipExtracting}
|
||||
className="text-sm text-text-muted file:mr-3 file:py-1.5 file:px-3 file:rounded-lg file:border file:border-border file:text-xs file:bg-bg-subtle file:text-text-main hover:file:bg-bg-hover cursor-pointer disabled:opacity-50"
|
||||
/>
|
||||
{bulkZipExtracting && (
|
||||
<p className="text-xs text-text-muted animate-pulse">
|
||||
{t("codexImportBulkZipExtracting") || "Extracting ZIP…"}
|
||||
</p>
|
||||
)}
|
||||
{bulkZipError && <p className="text-sm text-red-500">{bulkZipError}</p>}
|
||||
<p className="text-xs text-text-muted">
|
||||
{t("codexImportBulkZipHint") ||
|
||||
"Upload a .zip containing auth.json files (max 50 files, 10 MB)"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Entry preview list */}
|
||||
{bulkEntries.length > 0 && !bulkResult && (
|
||||
<div className="flex flex-col gap-1 max-h-40 overflow-y-auto rounded-lg border border-border bg-bg-subtle p-2">
|
||||
<p className="text-xs font-medium text-text-muted px-1">
|
||||
{validBulkCount} / {bulkEntries.length} valid
|
||||
</p>
|
||||
{bulkEntries.map((entry, i) => (
|
||||
<div key={i} className="flex items-center gap-2 px-2 py-1 rounded">
|
||||
<span
|
||||
className={`material-symbols-outlined text-[14px] ${entry.parseError ? "text-red-500" : "text-emerald-500"}`}
|
||||
>
|
||||
{entry.parseError ? "error" : "check_circle"}
|
||||
</span>
|
||||
<span className="text-xs text-text-main flex-1 truncate">{entry.name}</span>
|
||||
{entry.parseError && (
|
||||
<span className="text-xs text-red-400">{entry.parseError}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Overwrite checkbox */}
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={bulkOverwrite}
|
||||
onChange={(e) => setBulkOverwrite(e.target.checked)}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
<span className="text-sm text-text-main">
|
||||
{t("codexImportOverwriteLabel") ||
|
||||
"Replace existing connections if accounts already exist"}
|
||||
</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={pasteText}
|
||||
onChange={(e) => handlePasteChange(e.target.value)}
|
||||
rows={8}
|
||||
placeholder='{ "auth_mode": "chatgpt", ... }'
|
||||
className="w-full rounded-lg border border-border bg-bg-subtle px-3 py-2 text-xs font-mono text-text-main placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/40 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Result panel */}
|
||||
{bulkResult && (
|
||||
<div
|
||||
className={`rounded-lg border px-4 py-3 text-sm ${
|
||||
bulkResult.failed === 0
|
||||
? "bg-emerald-500/10 border-emerald-500/20 text-emerald-400"
|
||||
: "bg-amber-500/10 border-amber-500/20 text-amber-400"
|
||||
}`}
|
||||
>
|
||||
<p className="font-medium">
|
||||
{bulkResult.success}{" "}
|
||||
{t("codexImportBulkSuccess", { count: bulkResult.success }) || "imported"} ·{" "}
|
||||
{bulkResult.failed}{" "}
|
||||
{t("codexImportBulkFailed", { count: bulkResult.failed }) || "failed"}
|
||||
</p>
|
||||
{bulkResult.errors.length > 0 && (
|
||||
<ul className="mt-2 space-y-0.5 text-xs">
|
||||
{bulkResult.errors.map((e, i) => (
|
||||
<li key={i}>
|
||||
<span className="font-medium">{e.name}:</span> {e.message}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Button
|
||||
onClick={handleBulkSubmit}
|
||||
disabled={!bulkCanSubmit}
|
||||
loading={bulkLoading}
|
||||
fullWidth
|
||||
>
|
||||
{bulkLoading
|
||||
? t("saving") || "Importing…"
|
||||
: typeof t.has === "function" && t.has("codexImportBulkSubmit")
|
||||
? t("codexImportBulkSubmit", { count: validBulkCount })
|
||||
: `Import ${validBulkCount} accounts`}
|
||||
</Button>
|
||||
<Button onClick={onClose} variant="ghost" fullWidth>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Parse error */}
|
||||
{parseError && <p className="text-sm text-red-500">{parseError}</p>}
|
||||
|
||||
{/* Detected email preview */}
|
||||
{detectedEmail && !parseError && (
|
||||
<p className="text-xs text-text-muted">
|
||||
{t("codexImportDetectedEmail", { email: detectedEmail }) ||
|
||||
`Detected: ${detectedEmail}`}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Shared fields */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-sm font-medium text-text-main">
|
||||
{t("codexImportEmailLabel") || "Account email"}
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="user@example.com"
|
||||
className="rounded-lg border border-border bg-bg-subtle px-3 py-2 text-sm text-text-main placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/40"
|
||||
/>
|
||||
<p className="text-xs text-text-muted">
|
||||
{t("codexImportEmailHint") || "Auto-detected from the file; edit if needed."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-sm font-medium text-text-main">
|
||||
{t("codexImportNameLabel") || "Connection name (optional)"}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={email || "Codex (imported)"}
|
||||
className="rounded-lg border border-border bg-bg-subtle px-3 py-2 text-sm text-text-main placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/40"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={overwriteExisting}
|
||||
onChange={(e) => setOverwriteExisting(e.target.checked)}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
<span className="text-sm text-text-main">
|
||||
{t("codexImportOverwriteLabel") ||
|
||||
"Replace existing connection if account already exists"}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Result error banner */}
|
||||
{resultError && (
|
||||
<div className="rounded-lg bg-red-500/10 border border-red-500/20 px-4 py-3 text-sm text-red-400">
|
||||
{resultError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Button onClick={handleSubmit} disabled={!canSubmit} loading={loading} fullWidth>
|
||||
{t("codexImportSubmit") || "Import"}
|
||||
</Button>
|
||||
<Button onClick={onClose} variant="ghost" fullWidth>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
112
src/app/api/providers/codex-auth/import-bulk/route.ts
Normal file
112
src/app/api/providers/codex-auth/import-bulk/route.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { CodexAuthFileError } from "@/lib/oauth/utils/codexAuthFile";
|
||||
import {
|
||||
parseAndValidateCodexAuth,
|
||||
createConnectionFromAuthFile,
|
||||
} from "@/lib/oauth/utils/codexAuthImport";
|
||||
import { getAuditRequestContext, logAuditEvent } from "@/lib/compliance/index";
|
||||
import { getProviderAuditTarget } from "@/lib/compliance/providerAudit";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
|
||||
import { importCodexAuthBulkSchema } from "@/shared/validation/schemas";
|
||||
import { validateBody, isValidationFailure } from "@/shared/validation/helpers";
|
||||
import { sanitizeProviderSpecificDataForResponse } from "@/lib/providers/requestDefaults";
|
||||
|
||||
function sanitizeConnectionForResponse(connection: Record<string, unknown>) {
|
||||
const safe = { ...connection };
|
||||
delete safe.accessToken;
|
||||
delete safe.refreshToken;
|
||||
delete safe.idToken;
|
||||
delete safe.apiKey;
|
||||
if (safe.providerSpecificData) {
|
||||
safe.providerSpecificData = sanitizeProviderSpecificDataForResponse(safe.providerSpecificData);
|
||||
}
|
||||
return safe;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authError = await requireManagementAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
const auditContext = getAuditRequestContext(request);
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const parsedBody = validateBody(importCodexAuthBulkSchema, body);
|
||||
if (isValidationFailure(parsedBody)) {
|
||||
return NextResponse.json({ error: parsedBody.error }, { status: 400 });
|
||||
}
|
||||
|
||||
const { entries, overwriteExisting } = parsedBody.data;
|
||||
|
||||
const created: Record<string, unknown>[] = [];
|
||||
const errors: { index: number; name: string; message: string }[] = [];
|
||||
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const e = entries[i];
|
||||
const label = e.name || `entry ${i + 1}`;
|
||||
try {
|
||||
const parsedAuth = parseAndValidateCodexAuth(e.json);
|
||||
const { connection } = await createConnectionFromAuthFile(parsedAuth, {
|
||||
name: e.name,
|
||||
email: e.email,
|
||||
overwriteExisting,
|
||||
});
|
||||
|
||||
const safe = sanitizeConnectionForResponse(connection as Record<string, unknown>);
|
||||
created.push(safe);
|
||||
|
||||
logAuditEvent({
|
||||
action: "provider.credentials.imported",
|
||||
actor: "admin",
|
||||
target: getProviderAuditTarget(connection),
|
||||
resourceType: "provider_credentials",
|
||||
status: "success",
|
||||
ipAddress: auditContext.ipAddress || undefined,
|
||||
requestId: auditContext.requestId,
|
||||
metadata: {
|
||||
provider: "codex",
|
||||
email: parsedAuth.email || e.email,
|
||||
bulkIndex: i,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
let message: string;
|
||||
if (err instanceof CodexAuthFileError) {
|
||||
message = err.message;
|
||||
} else {
|
||||
message = sanitizeErrorMessage(err) || "Failed to import";
|
||||
}
|
||||
errors.push({ index: i, name: label, message });
|
||||
}
|
||||
}
|
||||
|
||||
logAuditEvent({
|
||||
action: "provider.credentials.bulk_imported",
|
||||
actor: "admin",
|
||||
target: "codex",
|
||||
resourceType: "provider_credentials",
|
||||
status: errors.length === entries.length ? "failure" : "success",
|
||||
ipAddress: auditContext.ipAddress || undefined,
|
||||
requestId: auditContext.requestId,
|
||||
metadata: {
|
||||
provider: "codex",
|
||||
total: entries.length,
|
||||
success: created.length,
|
||||
failed: errors.length,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: created.length,
|
||||
failed: errors.length,
|
||||
total: entries.length,
|
||||
created,
|
||||
errors,
|
||||
});
|
||||
}
|
||||
52
src/app/api/providers/codex-auth/zip-extract/route.ts
Normal file
52
src/app/api/providers/codex-auth/zip-extract/route.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { extractCodexAuthZip } from "@/lib/oauth/utils/codexAuthZipExtract";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
|
||||
|
||||
const ZIP_BODY_LIMIT = 11 * 1024 * 1024; // 11 MB — slightly above the 10 MB extracted limit
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authError = await requireManagementAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
const contentLength = Number(request.headers.get("content-length") || "0");
|
||||
if (contentLength > ZIP_BODY_LIMIT) {
|
||||
return NextResponse.json(
|
||||
{ error: "ZIP file exceeds the 10 MB size limit", code: "file_too_large" },
|
||||
{ status: 413 }
|
||||
);
|
||||
}
|
||||
|
||||
let buffer: Buffer;
|
||||
try {
|
||||
const arrayBuffer = await request.arrayBuffer();
|
||||
if (arrayBuffer.byteLength > ZIP_BODY_LIMIT) {
|
||||
return NextResponse.json(
|
||||
{ error: "ZIP file exceeds the 10 MB size limit", code: "file_too_large" },
|
||||
{ status: 413 }
|
||||
);
|
||||
}
|
||||
buffer = Buffer.from(arrayBuffer);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Failed to read request body" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const files = extractCodexAuthZip(buffer);
|
||||
|
||||
const entries = files.map((f) => {
|
||||
try {
|
||||
return { name: f.name, json: JSON.parse(f.content), parseError: null };
|
||||
} catch {
|
||||
return { name: f.name, json: null, parseError: "Not valid JSON" };
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({ entries });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: sanitizeErrorMessage(error) || "Failed to extract ZIP", code: "extract_failed" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3114,6 +3114,8 @@
|
||||
"codexAuthExportFailed": "Failed to export Codex auth.json",
|
||||
"importCodexAuth": "Import auth",
|
||||
"codexImportModalTitle": "Import Codex Auth",
|
||||
"codexImportTabSingle": "Single",
|
||||
"codexImportTabBulk": "Bulk",
|
||||
"codexImportTabUpload": "Upload file",
|
||||
"codexImportTabPaste": "Paste JSON",
|
||||
"codexImportFileLabel": "Choose auth.json",
|
||||
@@ -3131,6 +3133,18 @@
|
||||
"codexImportFailed": "Failed to import Codex auth",
|
||||
"codexImportDetectedEmail": "Detected: {email}",
|
||||
"codexImportNoEmailDetected": "No email detected in file",
|
||||
"codexImportBulkModeUpload": "Upload files",
|
||||
"codexImportBulkModePaste": "Paste list",
|
||||
"codexImportBulkModeZip": "ZIP archive",
|
||||
"codexImportBulkUploadHint": "Select multiple .json files or drag and drop",
|
||||
"codexImportBulkPasteHint": "JSON array [ {...}, {...} ] or multiple JSONs separated by --- on its own line",
|
||||
"codexImportBulkZipHint": "Upload a .zip containing auth.json files (max 50 files, 10 MB)",
|
||||
"codexImportBulkSubmit": "Import {count} accounts",
|
||||
"codexImportBulkLimit": "Max 50 files per import",
|
||||
"codexImportBulkSuccess": "{count} imported",
|
||||
"codexImportBulkFailed": "{count} failed",
|
||||
"codexImportBulkZipExtracting": "Extracting ZIP…",
|
||||
"codexImportBulkZipError": "Failed to extract ZIP",
|
||||
"advancedSettings": "Advanced Settings",
|
||||
"chatPathLabel": "Chat Endpoint Path",
|
||||
"chatPathPlaceholder": "/chat/completions",
|
||||
|
||||
89
src/lib/oauth/utils/codexAuthZipExtract.ts
Normal file
89
src/lib/oauth/utils/codexAuthZipExtract.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import path from "path";
|
||||
import { unzipSync, type Unzipped } from "fflate";
|
||||
|
||||
export interface ExtractedZipFile {
|
||||
name: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface ExtractZipOptions {
|
||||
maxFiles?: number;
|
||||
maxFileSizeBytes?: number;
|
||||
maxTotalSizeBytes?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_FILES = 50;
|
||||
const DEFAULT_MAX_FILE_SIZE = 256 * 1024;
|
||||
const DEFAULT_MAX_TOTAL = 10 * 1024 * 1024;
|
||||
|
||||
function isSafeEntryName(name: string): boolean {
|
||||
if (!name.toLowerCase().endsWith(".json")) return false;
|
||||
if (name.includes("..")) return false;
|
||||
if (path.isAbsolute(name)) return false;
|
||||
if (/[\r\n\0]/.test(name)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function extractCodexAuthZip(
|
||||
zipBuffer: Buffer,
|
||||
options: ExtractZipOptions = {}
|
||||
): ExtractedZipFile[] {
|
||||
const maxFiles = options.maxFiles ?? DEFAULT_MAX_FILES;
|
||||
const maxFileSize = options.maxFileSizeBytes ?? DEFAULT_MAX_FILE_SIZE;
|
||||
const maxTotal = options.maxTotalSizeBytes ?? DEFAULT_MAX_TOTAL;
|
||||
|
||||
let unzipped: Unzipped;
|
||||
try {
|
||||
unzipped = unzipSync(new Uint8Array(zipBuffer));
|
||||
} catch {
|
||||
throw new Error("Could not parse ZIP archive — file may be corrupt or not a valid ZIP");
|
||||
}
|
||||
|
||||
const entries = Object.entries(unzipped).filter(([, data]) => data !== undefined);
|
||||
const jsonEntries = entries.filter(([name]) => name.toLowerCase().endsWith(".json"));
|
||||
|
||||
if (jsonEntries.length === 0) {
|
||||
throw new Error("ZIP archive contains no .json files");
|
||||
}
|
||||
|
||||
if (jsonEntries.length > maxFiles) {
|
||||
throw new Error(
|
||||
`ZIP archive contains ${jsonEntries.length} .json files — max allowed is ${maxFiles}`
|
||||
);
|
||||
}
|
||||
|
||||
let totalBytes = 0;
|
||||
const result: ExtractedZipFile[] = [];
|
||||
|
||||
for (const [entryName, data] of jsonEntries) {
|
||||
const baseName = path.basename(entryName);
|
||||
|
||||
if (!isSafeEntryName(baseName)) {
|
||||
throw new Error(
|
||||
`ZIP entry "${baseName}" has an unsafe filename (must be a .json file without path traversal)`
|
||||
);
|
||||
}
|
||||
|
||||
if (!isSafeEntryName(entryName)) {
|
||||
throw new Error(
|
||||
`ZIP entry path "${entryName}" is unsafe (no "..", absolute paths, or control characters allowed)`
|
||||
);
|
||||
}
|
||||
|
||||
if (data.byteLength > maxFileSize) {
|
||||
throw new Error(
|
||||
`ZIP entry "${baseName}" is ${data.byteLength} bytes — exceeds ${maxFileSize} byte limit per file`
|
||||
);
|
||||
}
|
||||
|
||||
totalBytes += data.byteLength;
|
||||
if (totalBytes > maxTotal) {
|
||||
throw new Error(`ZIP archive total uncompressed size exceeds ${maxTotal} byte limit`);
|
||||
}
|
||||
|
||||
const content = new TextDecoder("utf-8").decode(data);
|
||||
result.push({ name: baseName, content });
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -337,6 +337,22 @@ export const importCodexAuthSchema = z.object({
|
||||
overwriteExisting: z.boolean().optional(),
|
||||
});
|
||||
|
||||
// ──── Codex Import Bulk Schema ────
|
||||
|
||||
export const importCodexAuthBulkSchema = z.object({
|
||||
entries: z
|
||||
.array(
|
||||
z.object({
|
||||
json: z.unknown(),
|
||||
name: z.string().min(1).max(200).optional(),
|
||||
email: z.string().email("Must be a valid email").optional(),
|
||||
})
|
||||
)
|
||||
.min(1, "At least one entry is required")
|
||||
.max(50, "At most 50 entries per bulk import"),
|
||||
overwriteExisting: z.boolean().optional(),
|
||||
});
|
||||
|
||||
// ──── API Key Schemas ────
|
||||
|
||||
export const createKeySchema = z.object({
|
||||
|
||||
187
tests/unit/codexAuthImportBulk.test.ts
Normal file
187
tests/unit/codexAuthImportBulk.test.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { z } from "zod";
|
||||
|
||||
// Local copy of importCodexAuthBulkSchema — avoids importing Next.js deps.
|
||||
const importCodexAuthBulkSchema = z.object({
|
||||
entries: z
|
||||
.array(
|
||||
z.object({
|
||||
json: z.unknown(),
|
||||
name: z.string().min(1).max(200).optional(),
|
||||
email: z.string().email().optional(),
|
||||
})
|
||||
)
|
||||
.min(1)
|
||||
.max(50),
|
||||
overwriteExisting: z.boolean().optional(),
|
||||
});
|
||||
|
||||
function parse(body: unknown) {
|
||||
return importCodexAuthBulkSchema.safeParse(body);
|
||||
}
|
||||
|
||||
// ──── Schema tests ────────────────────────────────────────────────────────────
|
||||
|
||||
test("bulk schema: valid single entry passes", () => {
|
||||
const result = parse({ entries: [{ json: { auth_mode: "chatgpt" } }] });
|
||||
assert.ok(result.success);
|
||||
});
|
||||
|
||||
test("bulk schema: valid multiple entries pass", () => {
|
||||
const result = parse({
|
||||
entries: [
|
||||
{ json: {}, name: "Account A", email: "a@example.com" },
|
||||
{ json: {}, name: "Account B" },
|
||||
],
|
||||
overwriteExisting: true,
|
||||
});
|
||||
assert.ok(result.success);
|
||||
assert.equal(result.data.entries.length, 2);
|
||||
assert.equal(result.data.overwriteExisting, true);
|
||||
});
|
||||
|
||||
test("bulk schema: empty entries array fails", () => {
|
||||
const result = parse({ entries: [] });
|
||||
assert.ok(!result.success);
|
||||
});
|
||||
|
||||
test("bulk schema: missing entries fails", () => {
|
||||
const result = parse({});
|
||||
assert.ok(!result.success);
|
||||
});
|
||||
|
||||
test("bulk schema: 50 entries passes", () => {
|
||||
const entries = Array.from({ length: 50 }, (_, i) => ({ json: { index: i } }));
|
||||
const result = parse({ entries });
|
||||
assert.ok(result.success);
|
||||
});
|
||||
|
||||
test("bulk schema: 51 entries fails", () => {
|
||||
const entries = Array.from({ length: 51 }, (_, i) => ({ json: { index: i } }));
|
||||
const result = parse({ entries });
|
||||
assert.ok(!result.success);
|
||||
});
|
||||
|
||||
test("bulk schema: invalid email in entry fails", () => {
|
||||
const result = parse({
|
||||
entries: [{ json: {}, email: "not-an-email" }],
|
||||
});
|
||||
assert.ok(!result.success);
|
||||
const emailIssue = result.error.issues.find((i) => i.path.some((p) => p === "email"));
|
||||
assert.ok(emailIssue);
|
||||
});
|
||||
|
||||
test("bulk schema: empty name in entry fails", () => {
|
||||
const result = parse({ entries: [{ json: {}, name: "" }] });
|
||||
assert.ok(!result.success);
|
||||
});
|
||||
|
||||
test("bulk schema: overwriteExisting must be boolean", () => {
|
||||
const result = parse({
|
||||
entries: [{ json: {} }],
|
||||
overwriteExisting: "yes",
|
||||
});
|
||||
assert.ok(!result.success);
|
||||
});
|
||||
|
||||
// ──── Paste-list parser tests ─────────────────────────────────────────────────
|
||||
|
||||
// Client-side paste parser logic (mirrors what the UI does before submitting)
|
||||
function parsePasteList(text: string): Array<{ json: unknown; parseError: string | null }> {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return [];
|
||||
|
||||
// Try as a JSON array first
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed.map((item) => ({ json: item, parseError: null }));
|
||||
}
|
||||
// Single object
|
||||
return [{ json: parsed, parseError: null }];
|
||||
} catch {
|
||||
// Fall back to --- separator
|
||||
const parts = trimmed
|
||||
.split(/^---$/m)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
return parts.map((part) => {
|
||||
try {
|
||||
return { json: JSON.parse(part), parseError: null };
|
||||
} catch {
|
||||
return { json: null, parseError: "Invalid JSON" };
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test("paste parser: JSON array returns all items", () => {
|
||||
const text = JSON.stringify([{ auth_mode: "chatgpt" }, { auth_mode: "chatgpt" }]);
|
||||
const result = parsePasteList(text);
|
||||
assert.equal(result.length, 2);
|
||||
assert.ok(result.every((r) => r.parseError === null));
|
||||
});
|
||||
|
||||
test("paste parser: single JSON object returns one item", () => {
|
||||
const text = JSON.stringify({ auth_mode: "chatgpt" });
|
||||
const result = parsePasteList(text);
|
||||
assert.equal(result.length, 1);
|
||||
assert.equal(result[0].parseError, null);
|
||||
});
|
||||
|
||||
test("paste parser: --- separator splits multiple JSONs", () => {
|
||||
const a = JSON.stringify({ auth_mode: "chatgpt", index: 1 });
|
||||
const b = JSON.stringify({ auth_mode: "chatgpt", index: 2 });
|
||||
const text = `${a}\n---\n${b}`;
|
||||
const result = parsePasteList(text);
|
||||
assert.equal(result.length, 2);
|
||||
assert.ok(result.every((r) => r.parseError === null));
|
||||
});
|
||||
|
||||
test("paste parser: invalid JSON in --- section marked as error", () => {
|
||||
const good = JSON.stringify({ auth_mode: "chatgpt" });
|
||||
const text = `${good}\n---\nnot valid json`;
|
||||
const result = parsePasteList(text);
|
||||
assert.equal(result.length, 2);
|
||||
assert.equal(result[0].parseError, null);
|
||||
assert.ok(result[1].parseError !== null);
|
||||
});
|
||||
|
||||
test("paste parser: empty string returns empty array", () => {
|
||||
assert.equal(parsePasteList("").length, 0);
|
||||
assert.equal(parsePasteList(" ").length, 0);
|
||||
});
|
||||
|
||||
// ──── Partial-failure response shape ──────────────────────────────────────────
|
||||
|
||||
test("bulk response shape: partial success has correct structure", () => {
|
||||
const response = {
|
||||
success: 2,
|
||||
failed: 1,
|
||||
total: 3,
|
||||
created: [{ id: "a" }, { id: "b" }],
|
||||
errors: [{ index: 2, name: "entry 3", message: "duplicate_account" }],
|
||||
};
|
||||
|
||||
assert.equal(response.success + response.failed, response.total);
|
||||
assert.equal(response.created.length, response.success);
|
||||
assert.equal(response.errors.length, response.failed);
|
||||
assert.ok("index" in response.errors[0]);
|
||||
assert.ok("name" in response.errors[0]);
|
||||
assert.ok("message" in response.errors[0]);
|
||||
});
|
||||
|
||||
test("bulk response shape: all-failure has status failed", () => {
|
||||
const entries = 3;
|
||||
const failed = 3;
|
||||
const status = failed === entries ? "failure" : "success";
|
||||
assert.equal(status, "failure");
|
||||
});
|
||||
|
||||
test("bulk response shape: partial failure has status success", () => {
|
||||
const entries = 3;
|
||||
const failed = 1;
|
||||
const status = failed === entries ? "failure" : "success";
|
||||
assert.equal(status, "success");
|
||||
});
|
||||
134
tests/unit/codexAuthZipExtract.test.ts
Normal file
134
tests/unit/codexAuthZipExtract.test.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { zipSync, strToU8 } from "fflate";
|
||||
|
||||
// Mirror the safety logic from codexAuthZipExtract.ts so we can test without
|
||||
// importing the module (which is fine since it has no external DB deps, but
|
||||
// we test the pure logic to keep the test surface clear).
|
||||
|
||||
interface ExtractedZipFile {
|
||||
name: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface ExtractZipOptions {
|
||||
maxFiles?: number;
|
||||
maxFileSizeBytes?: number;
|
||||
maxTotalSizeBytes?: number;
|
||||
}
|
||||
|
||||
// Local re-implementation of the exported function to exercise it without
|
||||
// importing Node-only code in the test runner.
|
||||
import { extractCodexAuthZip } from "../../src/lib/oauth/utils/codexAuthZipExtract.ts";
|
||||
|
||||
// ──── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeZip(files: Record<string, string>): Buffer {
|
||||
const entries: Record<string, Uint8Array> = {};
|
||||
for (const [name, content] of Object.entries(files)) {
|
||||
entries[name] = strToU8(content);
|
||||
}
|
||||
return Buffer.from(zipSync(entries));
|
||||
}
|
||||
|
||||
const VALID_AUTH = JSON.stringify({ auth_mode: "chatgpt", tokens: {}, OPENAI_API_KEY: null });
|
||||
|
||||
// ──── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
test("extractCodexAuthZip: happy path — returns all .json entries", () => {
|
||||
const zip = makeZip({
|
||||
"auth-a.json": VALID_AUTH,
|
||||
"auth-b.json": VALID_AUTH,
|
||||
"auth-c.json": VALID_AUTH,
|
||||
});
|
||||
const files = extractCodexAuthZip(zip);
|
||||
assert.equal(files.length, 3);
|
||||
const names = files.map((f) => f.name).sort();
|
||||
assert.deepEqual(names, ["auth-a.json", "auth-b.json", "auth-c.json"]);
|
||||
});
|
||||
|
||||
test("extractCodexAuthZip: ignores non-.json entries", () => {
|
||||
const zip = makeZip({
|
||||
"auth-a.json": VALID_AUTH,
|
||||
"README.md": "# Readme",
|
||||
"config.yaml": "key: value",
|
||||
});
|
||||
const files = extractCodexAuthZip(zip);
|
||||
assert.equal(files.length, 1);
|
||||
assert.equal(files[0].name, "auth-a.json");
|
||||
});
|
||||
|
||||
test("extractCodexAuthZip: rejects archive with no .json files", () => {
|
||||
const zip = makeZip({ "README.md": "# Readme" });
|
||||
assert.throws(() => extractCodexAuthZip(zip), /no \.json files/i);
|
||||
});
|
||||
|
||||
test("extractCodexAuthZip: rejects entry with .. in path", () => {
|
||||
const zip = makeZip({ "../../../etc/passwd.json": '{"evil":true}' });
|
||||
assert.throws(() => extractCodexAuthZip(zip), /unsafe/i);
|
||||
});
|
||||
|
||||
test("extractCodexAuthZip: rejects absolute path entries", () => {
|
||||
const zip = makeZip({ "/etc/passwd.json": '{"evil":true}' });
|
||||
assert.throws(() => extractCodexAuthZip(zip), /unsafe/i);
|
||||
});
|
||||
|
||||
test("extractCodexAuthZip: rejects archive exceeding max file count", () => {
|
||||
const files: Record<string, string> = {};
|
||||
for (let i = 0; i <= 50; i++) {
|
||||
files[`auth-${i}.json`] = VALID_AUTH;
|
||||
}
|
||||
const zip = makeZip(files);
|
||||
assert.throws(() => extractCodexAuthZip(zip), /max allowed is 50/i);
|
||||
});
|
||||
|
||||
test("extractCodexAuthZip: respects custom maxFiles option", () => {
|
||||
const zip = makeZip({
|
||||
"auth-a.json": VALID_AUTH,
|
||||
"auth-b.json": VALID_AUTH,
|
||||
"auth-c.json": VALID_AUTH,
|
||||
});
|
||||
assert.throws(() => extractCodexAuthZip(zip, { maxFiles: 2 }), /max allowed is 2/i);
|
||||
});
|
||||
|
||||
test("extractCodexAuthZip: rejects individual file exceeding per-file cap", () => {
|
||||
const bigContent = "x".repeat(257 * 1024);
|
||||
const zip = makeZip({ "big.json": bigContent });
|
||||
assert.throws(() => extractCodexAuthZip(zip, { maxFileSizeBytes: 256 * 1024 }), /exceeds/i);
|
||||
});
|
||||
|
||||
test("extractCodexAuthZip: rejects total size exceeding cap", () => {
|
||||
// Each chunk is 4 MB — under the per-file override (5 MB) but 3 × 4 MB = 12 MB > 10 MB total
|
||||
const chunk = "x".repeat(4 * 1024 * 1024);
|
||||
const zip = makeZip({
|
||||
"a.json": chunk,
|
||||
"b.json": chunk,
|
||||
"c.json": chunk,
|
||||
});
|
||||
assert.throws(
|
||||
() =>
|
||||
extractCodexAuthZip(zip, {
|
||||
maxFileSizeBytes: 5 * 1024 * 1024,
|
||||
maxTotalSizeBytes: 10 * 1024 * 1024,
|
||||
}),
|
||||
/total/i
|
||||
);
|
||||
});
|
||||
|
||||
test("extractCodexAuthZip: content is returned as UTF-8 string", () => {
|
||||
const content = JSON.stringify({ hello: "wörld 🌍" });
|
||||
const zip = makeZip({ "auth.json": content });
|
||||
const files = extractCodexAuthZip(zip);
|
||||
assert.equal(files[0].content, content);
|
||||
});
|
||||
|
||||
test("extractCodexAuthZip: uses basename from nested path entries", () => {
|
||||
const zip = makeZip({ "subdir/auth-nested.json": VALID_AUTH });
|
||||
const files = extractCodexAuthZip(zip);
|
||||
assert.equal(files[0].name, "auth-nested.json");
|
||||
});
|
||||
|
||||
test("extractCodexAuthZip: rejects corrupt / non-ZIP buffer", () => {
|
||||
const notAZip = Buffer.from("this is not a zip file");
|
||||
assert.throws(() => extractCodexAuthZip(notAZip), /could not parse zip/i);
|
||||
});
|
||||
Reference in New Issue
Block a user