refactor(dashboard): extract auth-import modals — #3501 Phase 1a (#3634)

#3501 Phase 1a: extract 3 auth-import modal clusters.

Co-authored-by: oyi77 <oyi77@users.noreply.github.com>
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-11 08:54:38 -03:00
committed by GitHub
parent bb6d5a288a
commit 3a2cfd63ae
7 changed files with 2255 additions and 2167 deletions

View File

@@ -9,6 +9,7 @@
### ♻️ Code Quality
- **Provider-detail god-component decomposition — Phase 0** ([#3501]): introduced `ProviderDetailPageClient.tsx` and reduced `providers/[id]/page.tsx` to a thin 9-line route wrapper (was 12,882 LOC), following the repo's `*PageClient` convention. Added the first-ever smoke render test for the page (Hard Rule #8) as the safety net every later extraction phase is diffed against. Behavior unchanged; the `check-file-size` ratchet now tracks the extracted client. Foundation for Phases 16 (strangler-fig). Thanks @oyi77 for the parallel modularization effort in #3627.
- **Provider-detail god-component decomposition — Phase 1a** ([#3501]): extracted the three self-contained auth-import modal clusters (Codex/Claude/Gemini `Import*AuthModal` + `Apply*AuthModal` + their co-located helpers, ~2,160 LOC) into `providers/[id]/components/modals/`. `ProviderDetailPageClient.tsx` drops 12,882 → 10,719 LOC. Behavior unchanged (smoke test green; clusters had clean `{ onClose, onSuccess }` / inline-prop interfaces). Co-authored with @oyi77.
---

View File

@@ -48,7 +48,7 @@
"src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx": 2570,
"src/app/(dashboard)/dashboard/health/page.tsx": 1091,
"src/app/(dashboard)/dashboard/playground/components/tabs/ApiTab.tsx": 847,
"src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx": 12883,
"src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx": 10720,
"src/app/(dashboard)/dashboard/providers/components/onboarding/ProviderOnboardingWizard.tsx": 906,
"src/app/(dashboard)/dashboard/providers/page.tsx": 1925,
"src/app/(dashboard)/dashboard/runtime/RuntimePageClient.tsx": 1127,

View File

@@ -0,0 +1,730 @@
"use client";
import { useState } from "react";
import { useNotificationStore } from "@/store/notificationStore";
import { useTranslations } from "next-intl";
import { Button, Modal } from "@/shared/components";
interface ImportClaudeAuthModalProps {
onClose: () => void;
onSuccess: () => void;
}
type ClaudeImportTopTab = "single" | "bulk";
type ClaudeBulkSubMode = "upload" | "paste" | "zip";
interface ClaudeBulkEntry {
name: string;
json: unknown;
parseError: string | null;
email: string | null;
}
function extractEmailFromClaudeJson(json: unknown): string | null {
try {
const doc = json && typeof json === "object" ? (json as Record<string, unknown>) : null;
if (!doc) return null;
const oauth =
doc.claudeAiOauth && typeof doc.claudeAiOauth === "object"
? (doc.claudeAiOauth as Record<string, unknown>)
: null;
if (!oauth) return null;
return null; // email comes from bootstrap, not the file
} catch {
return null;
}
}
function previewClaudeJson(json: unknown): { valid: boolean; email: string | null } {
try {
const doc = json && typeof json === "object" ? (json as Record<string, unknown>) : null;
if (!doc) return { valid: false, email: null };
const oauth =
doc.claudeAiOauth && typeof doc.claudeAiOauth === "object"
? (doc.claudeAiOauth as Record<string, unknown>)
: null;
if (!oauth || !oauth.accessToken || !oauth.refreshToken) return { valid: false, email: null };
return { valid: true, email: null };
} catch {
return { valid: false, email: null };
}
}
export function ImportClaudeAuthModal({ onClose, onSuccess }: ImportClaudeAuthModalProps) {
const t = useTranslations("providers");
const notify = useNotificationStore();
const [topTab, setTopTab] = useState<ClaudeImportTopTab>("single");
const [singleSubTab, setSingleSubTab] = useState<"upload" | "paste">("upload");
const [bulkSubMode, setBulkSubMode] = useState<ClaudeBulkSubMode>("upload");
// Single
const [singleJson, setSingleJson] = useState<unknown>(null);
const [singlePasteText, setSinglePasteText] = useState("");
const [singleName, setSingleName] = useState("");
const [singleEmail, setSingleEmail] = useState("");
const [overwriteExisting, setOverwriteExisting] = useState(false);
const [submitting, setSubmitting] = useState(false);
// Bulk
const [bulkEntries, setBulkEntries] = useState<ClaudeBulkEntry[]>([]);
const [bulkPasteText, setBulkPasteText] = useState("");
const [bulkSubmitting, setBulkSubmitting] = useState(false);
const [bulkErrors, setBulkErrors] = useState<{ index: number; name: string; message: string }[]>(
[]
);
const [bulkResult, setBulkResult] = useState<{
success: number;
failed: number;
total: number;
} | null>(null);
const [zipExtracting, setZipExtracting] = useState(false);
const handleSingleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
try {
const json = JSON.parse(ev.target?.result as string);
setSingleJson(json);
} catch {
notify.error(
typeof t.has === "function" && t.has("claudeImportInvalidJson")
? t("claudeImportInvalidJson")
: "Could not parse the file as JSON"
);
}
};
reader.readAsText(file);
};
const handleSingleSubmit = async () => {
if (submitting) return;
setSubmitting(true);
try {
let rawJson: unknown;
if (singleSubTab === "upload") {
rawJson = singleJson;
} else {
try {
rawJson = JSON.parse(singlePasteText);
} catch {
notify.error(
typeof t.has === "function" && t.has("claudeImportInvalidJson")
? t("claudeImportInvalidJson")
: "Could not parse the pasted content as JSON"
);
return;
}
}
const body =
singleSubTab === "paste"
? {
source: { kind: "text", text: singlePasteText },
name: singleName || undefined,
email: singleEmail || undefined,
overwriteExisting,
}
: {
source: { kind: "json", json: rawJson },
name: singleName || undefined,
email: singleEmail || undefined,
overwriteExisting,
};
const res = await fetch("/api/providers/claude-auth/import", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
if (data.code === "duplicate_account") {
notify.error(
typeof t.has === "function" && t.has("claudeImportDuplicate")
? t("claudeImportDuplicate")
: 'Account already exists — enable "Replace existing" to overwrite'
);
} else if (data.code === "identity_unverified") {
notify.error(
typeof t.has === "function" && t.has("claudeImportIdentityUnverified")
? t("claudeImportIdentityUnverified")
: 'Bootstrap could not verify the account. Enable "Replace existing" or provide an email.'
);
} else {
notify.error(
data.error ||
(typeof t.has === "function" && t.has("claudeImportFailed")
? t("claudeImportFailed")
: "Failed to import Claude auth")
);
}
return;
}
notify.success(
typeof t.has === "function" && t.has("claudeImportSuccess")
? t("claudeImportSuccess")
: "Claude connection imported successfully"
);
onSuccess();
} catch {
notify.error(
typeof t.has === "function" && t.has("claudeImportFailed")
? t("claudeImportFailed")
: "Failed to import Claude auth"
);
} finally {
setSubmitting(false);
}
};
const handleBulkFilesChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
const newEntries: ClaudeBulkEntry[] = [];
let pending = files.length;
if (!pending) return;
files.forEach((file) => {
const reader = new FileReader();
reader.onload = (ev) => {
try {
const json = JSON.parse(ev.target?.result as string);
const email = extractEmailFromClaudeJson(json);
newEntries.push({
name: file.name.replace(/\.json$/, ""),
json,
parseError: null,
email,
});
} catch {
newEntries.push({
name: file.name,
json: null,
parseError: "Not valid JSON",
email: null,
});
}
pending--;
if (pending === 0) setBulkEntries((prev) => [...prev, ...newEntries]);
};
reader.readAsText(file);
});
};
const handleBulkPasteChange = (text: string) => {
setBulkPasteText(text);
const trimmed = text.trim();
if (!trimmed) {
setBulkEntries([]);
return;
}
try {
const arr = JSON.parse(trimmed);
if (Array.isArray(arr)) {
setBulkEntries(
arr.map((item, i) => ({
name: `entry ${i + 1}`,
json: item,
parseError: null,
email: null,
}))
);
} else {
setBulkEntries([{ name: "entry 1", json: arr, parseError: null, email: null }]);
}
} catch {
setBulkEntries([
{ name: "parse error", json: null, parseError: "Invalid JSON", email: null },
]);
}
};
const handleZipUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setZipExtracting(true);
try {
const res = await fetch("/api/providers/claude-auth/zip-extract", {
method: "POST",
headers: { "Content-Type": "application/zip" },
body: file,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
notify.error(
data.error ||
(typeof t.has === "function" && t.has("claudeImportBulkZipError")
? t("claudeImportBulkZipError")
: "Failed to extract ZIP")
);
return;
}
const entries: ClaudeBulkEntry[] = (data.entries || []).map(
(e: { name: string; json: unknown; parseError: string | null }) => ({
name: e.name,
json: e.json,
parseError: e.parseError,
email: null,
})
);
setBulkEntries(entries);
} catch {
notify.error(
typeof t.has === "function" && t.has("claudeImportBulkZipError")
? t("claudeImportBulkZipError")
: "Failed to extract ZIP"
);
} finally {
setZipExtracting(false);
}
};
const handleBulkSubmit = async () => {
if (bulkSubmitting) return;
setBulkSubmitting(true);
setBulkErrors([]);
setBulkResult(null);
try {
const validEntries = bulkEntries.filter((e) => e.json !== null);
if (validEntries.length === 0) {
notify.error("No valid entries to import");
return;
}
const res = await fetch("/api/providers/claude-auth/import-bulk", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
entries: validEntries.map((e) => ({
json: e.json,
name: e.name,
email: e.email || undefined,
})),
overwriteExisting,
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
notify.error(
data.error ||
(typeof t.has === "function" && t.has("claudeImportBulkFailed")
? t("claudeImportBulkFailed")
: "Some entries failed to import")
);
return;
}
setBulkResult({ success: data.success, failed: data.failed, total: data.total });
if (data.errors?.length > 0) setBulkErrors(data.errors);
if (data.success > 0) {
notify.success(
typeof t.has === "function" && t.has("claudeImportBulkSuccess")
? t("claudeImportBulkSuccess", { count: data.success })
: `Imported ${data.success} Claude connections`
);
if (data.failed === 0) onSuccess();
}
} catch {
notify.error(
typeof t.has === "function" && t.has("claudeImportBulkFailed")
? t("claudeImportBulkFailed")
: "Some entries failed to import"
);
} finally {
setBulkSubmitting(false);
}
};
const tabLabels: Record<ClaudeImportTopTab, string> = {
single:
typeof t.has === "function" && t.has("claudeImportTabSingle")
? t("claudeImportTabSingle")
: "Single",
bulk:
typeof t.has === "function" && t.has("claudeImportTabBulk")
? t("claudeImportTabBulk")
: "Bulk",
};
const modalTitle =
typeof t.has === "function" && t.has("claudeImportModalTitle")
? t("claudeImportModalTitle")
: "Import Claude Auth";
return (
<Modal isOpen onClose={onClose} title={modalTitle}>
<div className="flex flex-col gap-4">
{/* Top tabs */}
<div className="flex gap-1 border-b border-border pb-0">
{(["single", "bulk"] as ClaudeImportTopTab[]).map((tab) => (
<button
key={tab}
onClick={() => setTopTab(tab)}
className={`px-3 py-1.5 text-sm rounded-t-md transition-colors ${
topTab === tab
? "bg-primary/10 text-primary border-b-2 border-primary"
: "text-text-muted hover:text-text-primary"
}`}
>
{tabLabels[tab]}
</button>
))}
</div>
{topTab === "single" && (
<div className="flex flex-col gap-3">
{/* Sub-tabs */}
<div className="flex gap-1">
{(["upload", "paste"] as const).map((sub) => (
<button
key={sub}
onClick={() => setSingleSubTab(sub)}
className={`px-2 py-1 text-xs rounded transition-colors ${
singleSubTab === sub
? "bg-bg-subtle text-text-primary"
: "text-text-muted hover:text-text-primary"
}`}
>
{sub === "upload"
? typeof t.has === "function" && t.has("claudeImportTabUpload")
? t("claudeImportTabUpload")
: "Upload file"
: typeof t.has === "function" && t.has("claudeImportTabPaste")
? t("claudeImportTabPaste")
: "Paste JSON"}
</button>
))}
</div>
{singleSubTab === "upload" ? (
<div>
<label className="block text-xs text-text-muted mb-1">
{typeof t.has === "function" && t.has("claudeImportFileLabel")
? t("claudeImportFileLabel")
: "Choose .credentials.json"}
</label>
<input
type="file"
accept=".json"
onChange={handleSingleFileChange}
className="block w-full text-sm"
/>
{singleJson && previewClaudeJson(singleJson).valid && (
<p className="mt-1 text-xs text-emerald-500">
{t("providerDetailValidClaudeCredentialsFile")}
</p>
)}
{singleJson && !previewClaudeJson(singleJson).valid && (
<p className="mt-1 text-xs text-red-500">
{typeof t.has === "function" && t.has("claudeImportInvalidShape")
? t("claudeImportInvalidShape")
: "The file is not a valid .credentials.json"}
</p>
)}
</div>
) : (
<div>
<label className="block text-xs text-text-muted mb-1">
{typeof t.has === "function" && t.has("claudeImportPasteLabel")
? t("claudeImportPasteLabel")
: "Paste the JSON content"}
</label>
<textarea
value={singlePasteText}
onChange={(e) => setSinglePasteText(e.target.value)}
rows={6}
className="w-full rounded border border-border bg-bg-subtle px-2 py-1.5 text-xs font-mono text-text-main"
placeholder='{ "claudeAiOauth": { ... } }'
/>
</div>
)}
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-xs text-text-muted mb-1">
{typeof t.has === "function" && t.has("claudeImportEmailLabel")
? t("claudeImportEmailLabel")
: "Account email"}
</label>
<input
type="email"
value={singleEmail}
onChange={(e) => setSingleEmail(e.target.value)}
placeholder="auto-detected"
className="w-full rounded border border-border bg-bg-subtle px-2 py-1.5 text-xs text-text-main"
/>
</div>
<div>
<label className="block text-xs text-text-muted mb-1">
{typeof t.has === "function" && t.has("claudeImportNameLabel")
? t("claudeImportNameLabel")
: "Connection name (optional)"}
</label>
<input
type="text"
value={singleName}
onChange={(e) => setSingleName(e.target.value)}
placeholder="My Claude account"
className="w-full rounded border border-border bg-bg-subtle px-2 py-1.5 text-xs text-text-main"
/>
</div>
</div>
<label className="flex items-center gap-2 text-xs text-text-muted">
<input
type="checkbox"
checked={overwriteExisting}
onChange={(e) => setOverwriteExisting(e.target.checked)}
/>
{typeof t.has === "function" && t.has("claudeImportOverwriteLabel")
? t("claudeImportOverwriteLabel")
: "Replace existing connection if account already exists"}
</label>
<Button
loading={submitting}
onClick={handleSingleSubmit}
disabled={singleSubTab === "upload" ? !singleJson : !singlePasteText.trim()}
>
{typeof t.has === "function" && t.has("claudeImportSubmit")
? t("claudeImportSubmit")
: "Import"}
</Button>
</div>
)}
{topTab === "bulk" && (
<div className="flex flex-col gap-3">
{/* Sub-mode tabs */}
<div className="flex gap-1">
{(["upload", "paste", "zip"] as ClaudeBulkSubMode[]).map((mode) => (
<button
key={mode}
onClick={() => {
setBulkSubMode(mode);
setBulkEntries([]);
}}
className={`px-2 py-1 text-xs rounded transition-colors ${
bulkSubMode === mode
? "bg-bg-subtle text-text-primary"
: "text-text-muted hover:text-text-primary"
}`}
>
{mode === "upload"
? typeof t.has === "function" && t.has("claudeImportBulkModeUpload")
? t("claudeImportBulkModeUpload")
: "Upload files"
: mode === "paste"
? typeof t.has === "function" && t.has("claudeImportBulkModePaste")
? t("claudeImportBulkModePaste")
: "Paste JSON array"
: typeof t.has === "function" && t.has("claudeImportBulkModeZip")
? t("claudeImportBulkModeZip")
: "Upload ZIP"}
</button>
))}
</div>
{bulkSubMode === "upload" && (
<div>
<p className="text-xs text-text-muted mb-1">
{typeof t.has === "function" && t.has("claudeImportBulkUploadHint")
? t("claudeImportBulkUploadHint")
: "Drop or pick up to 50 .credentials.json files (256KB each, 10MB total)."}
</p>
<input
type="file"
accept=".json"
multiple
onChange={handleBulkFilesChange}
className="block w-full text-sm"
/>
</div>
)}
{bulkSubMode === "paste" && (
<div>
<p className="text-xs text-text-muted mb-1">
{typeof t.has === "function" && t.has("claudeImportBulkPasteHint")
? t("claudeImportBulkPasteHint")
: "Paste an array of objects: [{ json, name?, email? }, ...]"}
</p>
<textarea
value={bulkPasteText}
onChange={(e) => handleBulkPasteChange(e.target.value)}
rows={6}
className="w-full rounded border border-border bg-bg-subtle px-2 py-1.5 text-xs font-mono text-text-main"
placeholder="[{ ... }, { ... }]"
/>
</div>
)}
{bulkSubMode === "zip" && (
<div>
<p className="text-xs text-text-muted mb-1">
{typeof t.has === "function" && t.has("claudeImportBulkZipHint")
? t("claudeImportBulkZipHint")
: "ZIP containing .json entries. Max 50 entries, 10MB unpacked."}
</p>
{zipExtracting ? (
<p className="text-xs text-primary animate-pulse">
{typeof t.has === "function" && t.has("claudeImportBulkZipExtracting")
? t("claudeImportBulkZipExtracting")
: "Extracting ZIP…"}
</p>
) : (
<input
type="file"
accept=".zip"
onChange={handleZipUpload}
className="block w-full text-sm"
/>
)}
</div>
)}
{bulkEntries.length > 0 && (
<div className="rounded border border-border bg-bg-subtle px-2 py-1.5 max-h-36 overflow-y-auto">
{bulkEntries.map((e, i) => (
<div
key={i}
className={`text-xs py-0.5 flex items-center gap-1 ${e.parseError ? "text-red-500" : "text-text-main"}`}
>
<span className="material-symbols-outlined text-[12px]">
{e.parseError ? "error" : "check_circle"}
</span>
{e.name}
{e.email ? ` (${e.email})` : ""}
{e.parseError ? `${e.parseError}` : ""}
</div>
))}
</div>
)}
<label className="flex items-center gap-2 text-xs text-text-muted">
<input
type="checkbox"
checked={overwriteExisting}
onChange={(e) => setOverwriteExisting(e.target.checked)}
/>
{typeof t.has === "function" && t.has("claudeImportOverwriteLabel")
? t("claudeImportOverwriteLabel")
: "Replace existing connection if account already exists"}
</label>
{bulkResult && (
<div className="rounded bg-bg-subtle px-2 py-1.5 text-xs">
{bulkResult.success}/{bulkResult.total} imported
{bulkResult.failed > 0 ? `, ${bulkResult.failed} failed` : ""}
</div>
)}
{bulkErrors.length > 0 && (
<div className="rounded border border-red-500/30 bg-red-500/5 px-2 py-1.5 max-h-28 overflow-y-auto">
{bulkErrors.map((e) => (
<div key={e.index} className="text-xs text-red-500 py-0.5">
{e.name}: {e.message}
</div>
))}
</div>
)}
<Button
loading={bulkSubmitting}
onClick={handleBulkSubmit}
disabled={bulkEntries.filter((e) => e.json !== null).length === 0}
>
{typeof t.has === "function" && t.has("claudeImportBulkSubmit")
? t("claudeImportBulkSubmit")
: "Import all"}
</Button>
</div>
)}
</div>
</Modal>
);
}
// ──── ApplyClaudeAuthModal ────────────────────────────────────────────────────
export function ApplyClaudeAuthModal({
connectionId,
inProgress,
onConfirm,
onClose,
}: {
connectionId: string | null;
inProgress: boolean;
onConfirm: (id: string) => Promise<void>;
onClose: () => void;
}) {
const t = useTranslations("providers");
const [confirmed, setConfirmed] = useState(false);
const isOpen = !!connectionId;
if (!connectionId) return null;
const title =
typeof t.has === "function" && t.has("claudeApplyModalTitle")
? t("claudeApplyModalTitle")
: "Apply to Local Claude Code";
const targetLabel =
typeof t.has === "function" && t.has("claudeApplyTargetLabel")
? t("claudeApplyTargetLabel")
: "Target path";
const backupLabel =
typeof t.has === "function" && t.has("claudeApplyBackupLabel")
? t("claudeApplyBackupLabel")
: "Backups";
const warning =
typeof t.has === "function" && t.has("claudeApplyWarning")
? t("claudeApplyWarning")
: "This will replace the existing claudeAiOauth section. Continue?";
const confirmText =
typeof t.has === "function" && t.has("claudeApplyConfirmCheckbox")
? t("claudeApplyConfirmCheckbox")
: "I confirm I want to replace the existing claudeAiOauth section";
const applyText =
typeof t.has === "function" && t.has("claudeApply") ? t("claudeApply") : "Apply";
const mcpHint =
typeof t.has === "function" && t.has("claudeApplyMcpHint")
? t("claudeApplyMcpHint")
: "Existing MCP OAuth state will be preserved.";
return (
<Modal isOpen={isOpen} title={title} onClose={onClose}>
<div className="flex flex-col gap-4">
<div>
<div className="text-xs uppercase text-text-muted mb-1">{targetLabel}</div>
<code className="block rounded bg-sidebar px-2 py-1.5 text-xs font-mono text-text-main">
~/.claude/.credentials.json
</code>
<p className="mt-1 text-xs text-text-muted">Path is auto-detected per OS (Linux/Mac).</p>
</div>
<div>
<div className="text-xs uppercase text-text-muted mb-1">{backupLabel}</div>
<code className="block rounded bg-sidebar px-2 py-1.5 text-xs font-mono text-text-main">
{"~/.claude/credentials-{timestamp}.bak"}
</code>
</div>
<div className="rounded bg-sky-500/10 border border-sky-500/20 px-3 py-2 text-xs text-sky-400">
{mcpHint}
</div>
<p className="text-sm text-text-muted">{warning}</p>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={confirmed}
onChange={(e) => setConfirmed(e.target.checked)}
/>
{confirmText}
</label>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={onClose} disabled={inProgress}>
Cancel
</Button>
<Button
loading={inProgress}
disabled={!confirmed || inProgress}
onClick={() => void onConfirm(connectionId)}
>
{applyText}
</Button>
</div>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,743 @@
"use client";
import { useState } from "react";
import { useNotificationStore } from "@/store/notificationStore";
import { useTranslations } from "next-intl";
import { Button, Modal, Select } from "@/shared/components";
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;
// Codex CLI no longer writes auth_mode — accept both with and without it.
// Only reject when auth_mode is explicitly set to something other than "chatgpt".
if (
!doc ||
(doc.auth_mode !== undefined && doc.auth_mode !== null && 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);
}
}
export function ImportCodexAuthModal({ onClose, onSuccess }: ImportCodexAuthModalProps) {
const t = useTranslations("providers");
const notify = useNotificationStore();
// 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 handleSingleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
try {
handleSinglePreview(JSON.parse(ev.target?.result as string));
} catch {
setSingleParseError(t("codexImportInvalidJson") || "Could not parse JSON");
}
};
reader.readAsText(file);
}
function handleSinglePasteChange(text: string) {
setSinglePasteText(text);
if (!text.trim()) {
setSingleParsedJson(null);
setSingleParseError(null);
setSingleDetectedEmail(null);
return;
}
try {
handleSinglePreview(JSON.parse(text));
} catch {
setSingleParseError(t("codexImportInvalidJson") || "Could not parse JSON");
setSingleParsedJson(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: singleParsedJson },
name: singleName.trim() || undefined,
email: singleEmail.trim() || undefined,
overwriteExisting: singleOverwrite,
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
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 {
setSingleError(t("codexImportFailed") || "Failed to import Codex auth");
} finally {
setSingleLoading(false);
}
}
// ── 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
isOpen
onClose={onClose}
title={t("codexImportModalTitle") || "Import Codex Auth"}
maxWidth="max-w-lg"
>
<div className="flex flex-col gap-4">
{/* Top-level Single / Bulk tabs */}
<div className="flex border-b border-border">
{TOP_TABS.map(({ id, label }) => (
<button
key={id}
onClick={() => {
setTopTab(id);
setBulkResult(null);
setSingleError(null);
}}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
topTab === id
? "border-primary text-primary"
: "border-transparent text-text-muted hover:text-text-main"
}`}
>
{label}
</button>
))}
</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>
</>
)}
{/* ── 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>
{/* 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>
</>
)}
</div>
</Modal>
);
}
export function ApplyCodexAuthModal({
connectionId,
inProgress,
onConfirm,
onClose,
}: {
connectionId: string | null;
inProgress: boolean;
onConfirm: (id: string) => Promise<void>;
onClose: () => void;
}) {
const t = useTranslations("providers");
// `key`-reset pattern: caller re-mounts the modal each open (different
// connectionId triggers a new instance), so local confirmation state is
// naturally fresh without any post-render bookkeeping.
const [confirmed, setConfirmed] = useState(false);
const isOpen = !!connectionId;
if (!connectionId) return null;
const title =
typeof t.has === "function" && t.has("codexApplyModalTitle")
? t("codexApplyModalTitle")
: "Apply to Local Codex";
const targetLabel =
typeof t.has === "function" && t.has("codexApplyTargetLabel")
? t("codexApplyTargetLabel")
: "Target path";
const backupLabel =
typeof t.has === "function" && t.has("codexApplyBackupLabel")
? t("codexApplyBackupLabel")
: "Backups";
const warning =
typeof t.has === "function" && t.has("codexApplyWarning")
? t("codexApplyWarning")
: "This will replace the existing auth.json. Continue?";
const confirmText =
typeof t.has === "function" && t.has("codexApplyConfirmCheckbox")
? t("codexApplyConfirmCheckbox")
: "I confirm I want to replace the existing auth.json";
const applyText = typeof t.has === "function" && t.has("codexApply") ? t("codexApply") : "Apply";
return (
<Modal isOpen={isOpen} title={title} onClose={onClose}>
<div className="flex flex-col gap-4">
<div>
<div className="text-xs uppercase text-text-muted mb-1">{targetLabel}</div>
<code className="block rounded bg-sidebar px-2 py-1.5 text-xs font-mono text-text-main">
~/.codex/auth.json
</code>
<p className="mt-1 text-xs text-text-muted">{t("providerDetailPathAutoDetectedAllOs")}</p>
</div>
<div>
<div className="text-xs uppercase text-text-muted mb-1">{backupLabel}</div>
<ul className="text-xs text-text-muted space-y-0.5 list-disc pl-4">
<li>
<code className="text-text-main">~/.codex/auth-&lt;timestamp&gt;.bak</code> quick
local rollback
</li>
<li>Centralized backup history (audit trail)</li>
</ul>
</div>
<div className="rounded-lg border border-amber-500/25 bg-amber-500/10 px-3 py-2 text-sm text-amber-200">
<div className="flex items-start gap-2">
<span className="material-symbols-outlined mt-0.5 text-[18px] text-amber-500">
warning
</span>
<span>{warning}</span>
</div>
</div>
<label className="flex items-center gap-2 text-sm text-text-muted cursor-pointer">
<input
type="checkbox"
checked={confirmed}
onChange={(e) => setConfirmed(e.target.checked)}
className="rounded border-border"
/>
{confirmText}
</label>
<div className="flex gap-2">
<Button
onClick={() => void onConfirm(connectionId)}
fullWidth
disabled={!confirmed || inProgress}
>
{inProgress ? t("saving") : applyText}
</Button>
<Button onClick={onClose} variant="ghost" fullWidth disabled={inProgress}>
{t("cancel")}
</Button>
</div>
</div>
</Modal>
);
}
// ──── ImportClaudeAuthModal ────────────────────────────────────────────────────

View File

@@ -0,0 +1,706 @@
"use client";
import { useState } from "react";
import { useNotificationStore } from "@/store/notificationStore";
import { useTranslations } from "next-intl";
import { Button, Modal } from "@/shared/components";
interface ImportGeminiAuthModalProps {
onClose: () => void;
onSuccess: () => void;
}
type GeminiImportTopTab = "single" | "bulk";
type GeminiBulkSubMode = "upload" | "paste" | "zip";
interface GeminiBulkEntry {
name: string;
json: unknown;
parseError: string | null;
email: string | null;
}
function extractEmailFromGeminiJwt(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 previewGeminiJson(json: unknown): { valid: boolean; email: string | null } {
try {
const doc = json && typeof json === "object" ? (json as Record<string, unknown>) : null;
if (!doc) return { valid: false, email: null };
if (!doc.access_token || !doc.refresh_token || !doc.id_token)
return { valid: false, email: null };
const email = typeof doc.id_token === "string" ? extractEmailFromGeminiJwt(doc.id_token) : null;
return { valid: true, email };
} catch {
return { valid: false, email: null };
}
}
export function ImportGeminiAuthModal({ onClose, onSuccess }: ImportGeminiAuthModalProps) {
const t = useTranslations("providers");
const notify = useNotificationStore();
const [topTab, setTopTab] = useState<GeminiImportTopTab>("single");
const [singleSubTab, setSingleSubTab] = useState<"upload" | "paste">("upload");
const [bulkSubMode, setBulkSubMode] = useState<GeminiBulkSubMode>("upload");
// Single
const [singleJson, setSingleJson] = useState<unknown>(null);
const [singlePasteText, setSinglePasteText] = useState("");
const [singleName, setSingleName] = useState("");
const [singleEmail, setSingleEmail] = useState("");
const [overwriteExisting, setOverwriteExisting] = useState(false);
const [submitting, setSubmitting] = useState(false);
// Bulk
const [bulkEntries, setBulkEntries] = useState<GeminiBulkEntry[]>([]);
const [bulkPasteText, setBulkPasteText] = useState("");
const [bulkSubmitting, setBulkSubmitting] = useState(false);
const [bulkErrors, setBulkErrors] = useState<{ index: number; name: string; message: string }[]>(
[]
);
const [bulkResult, setBulkResult] = useState<{
success: number;
failed: number;
total: number;
} | null>(null);
const [zipExtracting, setZipExtracting] = useState(false);
const handleSingleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
try {
const json = JSON.parse(ev.target?.result as string);
setSingleJson(json);
} catch {
notify.error(
typeof t.has === "function" && t.has("geminiImportInvalidJson")
? t("geminiImportInvalidJson")
: "Could not parse the file as JSON"
);
}
};
reader.readAsText(file);
};
const handleSingleSubmit = async () => {
if (submitting) return;
setSubmitting(true);
try {
const body =
singleSubTab === "paste"
? {
source: { kind: "text", text: singlePasteText },
name: singleName || undefined,
email: singleEmail || undefined,
overwriteExisting,
}
: {
source: { kind: "json", json: singleJson },
name: singleName || undefined,
email: singleEmail || undefined,
overwriteExisting,
};
const res = await fetch("/api/providers/gemini-cli-auth/import", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
if (data.code === "duplicate_account") {
notify.error(
typeof t.has === "function" && t.has("geminiImportDuplicate")
? t("geminiImportDuplicate")
: 'Account already exists — enable "Replace existing" to overwrite'
);
} else if (data.code === "identity_unverified") {
notify.error(
typeof t.has === "function" && t.has("geminiImportIdentityUnverified")
? t("geminiImportIdentityUnverified")
: 'Could not verify identity from id_token. Enable "Replace existing" or provide an email.'
);
} else {
notify.error(
data.error ||
(typeof t.has === "function" && t.has("geminiImportFailed")
? t("geminiImportFailed")
: "Failed to import Gemini auth")
);
}
return;
}
const preview = previewGeminiJson(singleJson);
notify.success(
typeof t.has === "function" && t.has("geminiImportSuccess")
? t("geminiImportSuccess")
: `Gemini connection imported successfully${preview.email ? ` (${preview.email})` : ""}`
);
onSuccess();
} catch {
notify.error(
typeof t.has === "function" && t.has("geminiImportFailed")
? t("geminiImportFailed")
: "Failed to import Gemini auth"
);
} finally {
setSubmitting(false);
}
};
const handleBulkFilesChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
const newEntries: GeminiBulkEntry[] = [];
let pending = files.length;
if (!pending) return;
files.forEach((file) => {
const reader = new FileReader();
reader.onload = (ev) => {
try {
const json = JSON.parse(ev.target?.result as string);
const { email } = previewGeminiJson(json);
newEntries.push({
name: file.name.replace(/\.json$/, ""),
json,
parseError: null,
email,
});
} catch {
newEntries.push({
name: file.name,
json: null,
parseError: "Not valid JSON",
email: null,
});
}
pending--;
if (pending === 0) setBulkEntries((prev) => [...prev, ...newEntries]);
};
reader.readAsText(file);
});
};
const handleBulkPasteChange = (text: string) => {
setBulkPasteText(text);
const trimmed = text.trim();
if (!trimmed) {
setBulkEntries([]);
return;
}
try {
const arr = JSON.parse(trimmed);
if (Array.isArray(arr)) {
setBulkEntries(
arr.map((item, i) => {
const { email } = previewGeminiJson(item);
return { name: email || `entry ${i + 1}`, json: item, parseError: null, email };
})
);
} else {
const { email } = previewGeminiJson(arr);
setBulkEntries([{ name: email || "entry 1", json: arr, parseError: null, email }]);
}
} catch {
setBulkEntries([
{ name: "parse error", json: null, parseError: "Invalid JSON", email: null },
]);
}
};
const handleZipUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setZipExtracting(true);
try {
const res = await fetch("/api/providers/gemini-cli-auth/zip-extract", {
method: "POST",
headers: { "Content-Type": "application/zip" },
body: file,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
notify.error(
data.error ||
(typeof t.has === "function" && t.has("geminiImportBulkZipError")
? t("geminiImportBulkZipError")
: "Failed to extract ZIP")
);
return;
}
const entries: GeminiBulkEntry[] = (data.entries || []).map(
(e: { name: string; json: unknown; parseError: string | null }) => {
const { email } = previewGeminiJson(e.json);
return { name: e.name, json: e.json, parseError: e.parseError, email };
}
);
setBulkEntries(entries);
} catch {
notify.error(
typeof t.has === "function" && t.has("geminiImportBulkZipError")
? t("geminiImportBulkZipError")
: "Failed to extract ZIP"
);
} finally {
setZipExtracting(false);
}
};
const handleBulkSubmit = async () => {
if (bulkSubmitting) return;
setBulkSubmitting(true);
setBulkErrors([]);
setBulkResult(null);
try {
const validEntries = bulkEntries.filter((e) => e.json !== null);
if (validEntries.length === 0) {
notify.error("No valid entries to import");
return;
}
const res = await fetch("/api/providers/gemini-cli-auth/import-bulk", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
entries: validEntries.map((e) => ({
json: e.json,
name: e.name,
email: e.email || undefined,
})),
overwriteExisting,
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
notify.error(
data.error ||
(typeof t.has === "function" && t.has("geminiImportBulkFailed")
? t("geminiImportBulkFailed")
: "Some entries failed to import")
);
return;
}
setBulkResult({ success: data.success, failed: data.failed, total: data.total });
if (data.errors?.length > 0) setBulkErrors(data.errors);
if (data.success > 0) {
notify.success(
typeof t.has === "function" && t.has("geminiImportBulkSuccess")
? t("geminiImportBulkSuccess", { count: data.success })
: `Imported ${data.success} Gemini connections`
);
if (data.failed === 0) onSuccess();
}
} catch {
notify.error(
typeof t.has === "function" && t.has("geminiImportBulkFailed")
? t("geminiImportBulkFailed")
: "Some entries failed to import"
);
} finally {
setBulkSubmitting(false);
}
};
const tabLabels: Record<GeminiImportTopTab, string> = {
single:
typeof t.has === "function" && t.has("geminiImportTabSingle")
? t("geminiImportTabSingle")
: "Single",
bulk:
typeof t.has === "function" && t.has("geminiImportTabBulk")
? t("geminiImportTabBulk")
: "Bulk",
};
const modalTitle =
typeof t.has === "function" && t.has("geminiImportModalTitle")
? t("geminiImportModalTitle")
: "Import Gemini Auth";
return (
<Modal isOpen onClose={onClose} title={modalTitle}>
<div className="flex flex-col gap-4">
<div className="flex gap-1 border-b border-border pb-0">
{(["single", "bulk"] as GeminiImportTopTab[]).map((tab) => (
<button
key={tab}
onClick={() => setTopTab(tab)}
className={`px-3 py-1.5 text-sm rounded-t-md transition-colors ${
topTab === tab
? "bg-primary/10 text-primary border-b-2 border-primary"
: "text-text-muted hover:text-text-primary"
}`}
>
{tabLabels[tab]}
</button>
))}
</div>
{topTab === "single" && (
<div className="flex flex-col gap-3">
<div className="flex gap-1">
{(["upload", "paste"] as const).map((sub) => (
<button
key={sub}
onClick={() => setSingleSubTab(sub)}
className={`px-2 py-1 text-xs rounded transition-colors ${
singleSubTab === sub
? "bg-bg-subtle text-text-primary"
: "text-text-muted hover:text-text-primary"
}`}
>
{sub === "upload"
? typeof t.has === "function" && t.has("geminiImportTabUpload")
? t("geminiImportTabUpload")
: "Upload file"
: typeof t.has === "function" && t.has("geminiImportTabPaste")
? t("geminiImportTabPaste")
: "Paste JSON"}
</button>
))}
</div>
{singleSubTab === "upload" ? (
<div>
<label className="block text-xs text-text-muted mb-1">
{typeof t.has === "function" && t.has("geminiImportFileLabel")
? t("geminiImportFileLabel")
: "Choose oauth_creds.json"}
</label>
<input
type="file"
accept=".json"
onChange={handleSingleFileChange}
className="block w-full text-sm"
/>
{singleJson && previewGeminiJson(singleJson).valid && (
<p className="mt-1 text-xs text-emerald-500">
Valid Gemini OAuth credentials
{previewGeminiJson(singleJson).email
? ` (${previewGeminiJson(singleJson).email})`
: ""}
</p>
)}
{singleJson && !previewGeminiJson(singleJson).valid && (
<p className="mt-1 text-xs text-red-500">
{typeof t.has === "function" && t.has("geminiImportInvalidShape")
? t("geminiImportInvalidShape")
: "The file is not a valid oauth_creds.json"}
</p>
)}
</div>
) : (
<div>
<label className="block text-xs text-text-muted mb-1">
{typeof t.has === "function" && t.has("geminiImportPasteLabel")
? t("geminiImportPasteLabel")
: "Paste the JSON content"}
</label>
<textarea
value={singlePasteText}
onChange={(e) => setSinglePasteText(e.target.value)}
rows={6}
className="w-full rounded border border-border bg-bg-subtle px-2 py-1.5 text-xs font-mono text-text-main"
placeholder='{ "access_token": "...", "refresh_token": "...", "id_token": "..." }'
/>
</div>
)}
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-xs text-text-muted mb-1">
{typeof t.has === "function" && t.has("geminiImportEmailLabel")
? t("geminiImportEmailLabel")
: "Account email"}
</label>
<input
type="email"
value={singleEmail}
onChange={(e) => setSingleEmail(e.target.value)}
placeholder="auto-detected from id_token"
className="w-full rounded border border-border bg-bg-subtle px-2 py-1.5 text-xs text-text-main"
/>
</div>
<div>
<label className="block text-xs text-text-muted mb-1">
{typeof t.has === "function" && t.has("geminiImportNameLabel")
? t("geminiImportNameLabel")
: "Connection name (optional)"}
</label>
<input
type="text"
value={singleName}
onChange={(e) => setSingleName(e.target.value)}
placeholder="My Gemini account"
className="w-full rounded border border-border bg-bg-subtle px-2 py-1.5 text-xs text-text-main"
/>
</div>
</div>
<label className="flex items-center gap-2 text-xs text-text-muted">
<input
type="checkbox"
checked={overwriteExisting}
onChange={(e) => setOverwriteExisting(e.target.checked)}
/>
{typeof t.has === "function" && t.has("geminiImportOverwriteLabel")
? t("geminiImportOverwriteLabel")
: "Replace existing connection if account already exists"}
</label>
<Button
loading={submitting}
onClick={handleSingleSubmit}
disabled={singleSubTab === "upload" ? !singleJson : !singlePasteText.trim()}
>
{typeof t.has === "function" && t.has("geminiImportSubmit")
? t("geminiImportSubmit")
: "Import"}
</Button>
</div>
)}
{topTab === "bulk" && (
<div className="flex flex-col gap-3">
<div className="flex gap-1">
{(["upload", "paste", "zip"] as GeminiBulkSubMode[]).map((mode) => (
<button
key={mode}
onClick={() => {
setBulkSubMode(mode);
setBulkEntries([]);
}}
className={`px-2 py-1 text-xs rounded transition-colors ${
bulkSubMode === mode
? "bg-bg-subtle text-text-primary"
: "text-text-muted hover:text-text-primary"
}`}
>
{mode === "upload"
? typeof t.has === "function" && t.has("geminiImportBulkModeUpload")
? t("geminiImportBulkModeUpload")
: "Upload files"
: mode === "paste"
? typeof t.has === "function" && t.has("geminiImportBulkModePaste")
? t("geminiImportBulkModePaste")
: "Paste JSON array"
: typeof t.has === "function" && t.has("geminiImportBulkModeZip")
? t("geminiImportBulkModeZip")
: "Upload ZIP"}
</button>
))}
</div>
{bulkSubMode === "upload" && (
<div>
<p className="text-xs text-text-muted mb-1">
{typeof t.has === "function" && t.has("geminiImportBulkUploadHint")
? t("geminiImportBulkUploadHint")
: "Drop or pick up to 50 oauth_creds.json files (256KB each, 10MB total)."}
</p>
<input
type="file"
accept=".json"
multiple
onChange={handleBulkFilesChange}
className="block w-full text-sm"
/>
</div>
)}
{bulkSubMode === "paste" && (
<div>
<p className="text-xs text-text-muted mb-1">
{typeof t.has === "function" && t.has("geminiImportBulkPasteHint")
? t("geminiImportBulkPasteHint")
: "Paste an array of objects: [{ json, name?, email? }, ...]"}
</p>
<textarea
value={bulkPasteText}
onChange={(e) => handleBulkPasteChange(e.target.value)}
rows={6}
className="w-full rounded border border-border bg-bg-subtle px-2 py-1.5 text-xs font-mono text-text-main"
placeholder="[{ ... }, { ... }]"
/>
</div>
)}
{bulkSubMode === "zip" && (
<div>
<p className="text-xs text-text-muted mb-1">
{typeof t.has === "function" && t.has("geminiImportBulkZipHint")
? t("geminiImportBulkZipHint")
: "ZIP containing oauth_creds.json entries. Max 50 entries, 10MB unpacked."}
</p>
{zipExtracting ? (
<p className="text-xs text-primary animate-pulse">
{typeof t.has === "function" && t.has("geminiImportBulkZipExtracting")
? t("geminiImportBulkZipExtracting")
: "Extracting ZIP…"}
</p>
) : (
<input
type="file"
accept=".zip"
onChange={handleZipUpload}
className="block w-full text-sm"
/>
)}
</div>
)}
{bulkEntries.length > 0 && (
<div className="rounded border border-border bg-bg-subtle px-2 py-1.5 max-h-36 overflow-y-auto">
{bulkEntries.map((e, i) => (
<div
key={i}
className={`text-xs py-0.5 flex items-center gap-1 ${e.parseError ? "text-red-500" : "text-text-main"}`}
>
<span className="material-symbols-outlined text-[12px]">
{e.parseError ? "error" : "check_circle"}
</span>
{e.name}
{e.email ? ` (${e.email})` : ""}
{e.parseError ? `${e.parseError}` : ""}
</div>
))}
</div>
)}
<label className="flex items-center gap-2 text-xs text-text-muted">
<input
type="checkbox"
checked={overwriteExisting}
onChange={(e) => setOverwriteExisting(e.target.checked)}
/>
{typeof t.has === "function" && t.has("geminiImportOverwriteLabel")
? t("geminiImportOverwriteLabel")
: "Replace existing connection if account already exists"}
</label>
{bulkResult && (
<div className="rounded bg-bg-subtle px-2 py-1.5 text-xs">
{bulkResult.success}/{bulkResult.total} imported
{bulkResult.failed > 0 ? `, ${bulkResult.failed} failed` : ""}
</div>
)}
{bulkErrors.length > 0 && (
<div className="rounded border border-red-500/30 bg-red-500/5 px-2 py-1.5 max-h-28 overflow-y-auto">
{bulkErrors.map((e) => (
<div key={e.index} className="text-xs text-red-500 py-0.5">
{e.name}: {e.message}
</div>
))}
</div>
)}
<Button
loading={bulkSubmitting}
onClick={handleBulkSubmit}
disabled={bulkEntries.filter((e) => e.json !== null).length === 0}
>
{typeof t.has === "function" && t.has("geminiImportBulkSubmit")
? t("geminiImportBulkSubmit")
: "Import all"}
</Button>
</div>
)}
</div>
</Modal>
);
}
// ──── ApplyGeminiAuthModal ────────────────────────────────────────────────────
export function ApplyGeminiAuthModal({
connectionId,
inProgress,
onConfirm,
onClose,
}: {
connectionId: string | null;
inProgress: boolean;
onConfirm: (id: string) => Promise<void>;
onClose: () => void;
}) {
const t = useTranslations("providers");
const [confirmed, setConfirmed] = useState(false);
const isOpen = !!connectionId;
if (!connectionId) return null;
const title =
typeof t.has === "function" && t.has("geminiApplyModalTitle")
? t("geminiApplyModalTitle")
: "Apply to Local Gemini CLI";
const targetLabel =
typeof t.has === "function" && t.has("geminiApplyTargetLabel")
? t("geminiApplyTargetLabel")
: "Target path";
const backupLabel =
typeof t.has === "function" && t.has("geminiApplyBackupLabel")
? t("geminiApplyBackupLabel")
: "Backups";
const warning =
typeof t.has === "function" && t.has("geminiApplyWarning")
? t("geminiApplyWarning")
: "This will replace the existing oauth_creds.json and update google_accounts.json. Continue?";
const confirmText =
typeof t.has === "function" && t.has("geminiApplyConfirmCheckbox")
? t("geminiApplyConfirmCheckbox")
: "I confirm I want to replace the existing oauth_creds.json";
const applyText =
typeof t.has === "function" && t.has("geminiApply") ? t("geminiApply") : "Apply";
const accountsHint =
typeof t.has === "function" && t.has("geminiApplyAccountsHint")
? t("geminiApplyAccountsHint")
: "The google_accounts.json active account will be updated to match this connection.";
return (
<Modal isOpen={isOpen} title={title} onClose={onClose}>
<div className="flex flex-col gap-4">
<div>
<div className="text-xs uppercase text-text-muted mb-1">{targetLabel}</div>
<code className="block rounded bg-sidebar px-2 py-1.5 text-xs font-mono text-text-main">
~/.gemini/oauth_creds.json
</code>
<p className="mt-1 text-xs text-text-muted">Path is auto-detected per OS (Linux/Mac).</p>
</div>
<div>
<div className="text-xs uppercase text-text-muted mb-1">{backupLabel}</div>
<code className="block rounded bg-sidebar px-2 py-1.5 text-xs font-mono text-text-main">
~/.gemini/oauth_creds-&#123;timestamp&#125;.bak
</code>
</div>
<div className="rounded bg-amber-500/10 border border-amber-500/20 px-3 py-2 text-xs text-amber-400">
{accountsHint}
</div>
<p className="text-sm text-text-muted">{warning}</p>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={confirmed}
onChange={(e) => setConfirmed(e.target.checked)}
/>
{confirmText}
</label>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={onClose} disabled={inProgress}>
Cancel
</Button>
<Button
loading={inProgress}
disabled={!confirmed || inProgress}
onClick={() => void onConfirm(connectionId)}
>
{applyText}
</Button>
</div>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,71 @@
// @vitest-environment jsdom
//
// Phase 1a regression test for Issue #3501. The three auth-import modal clusters
// (Codex/Claude/Gemini) were extracted out of the 12.8K-LOC god-component into
// standalone files. This proves each now mounts in isolation with its clean
// { onClose, onSuccess } interface — the payoff of the extraction (Hard Rule #8).
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ImportCodexAuthModal } from "../ImportCodexAuthModal";
import { ImportClaudeAuthModal } from "../ImportClaudeAuthModal";
import { ImportGeminiAuthModal } from "../ImportGeminiAuthModal";
vi.mock("next/navigation", () => ({
useParams: () => ({ id: "openai" }),
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
}));
vi.mock("next-intl", () => ({
useTranslations: (ns?: string) => (k: string) => (ns ? `${ns}.${k}` : k),
}));
const cleanups: Array<() => void> = [];
function renderModal(node: React.ReactElement) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
act(() => root.render(node));
cleanups.push(() => {
act(() => root.unmount());
container.remove();
});
return container;
}
describe("auth-import modals (Phase 1a extraction)", () => {
beforeEach(() => {
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
vi.stubGlobal(
"fetch",
vi.fn(() =>
Promise.resolve({ ok: true, json: async () => ({}), text: async () => "" } as Response)
)
);
});
afterEach(() => {
while (cleanups.length) cleanups.pop()?.();
document.body.innerHTML = "";
vi.unstubAllGlobals();
});
it("ImportCodexAuthModal mounts standalone", () => {
const onClose = vi.fn();
const onSuccess = vi.fn();
const c = renderModal(<ImportCodexAuthModal onClose={onClose} onSuccess={onSuccess} />);
expect(c.querySelector("*")).not.toBeNull();
});
it("ImportClaudeAuthModal mounts standalone", () => {
const c = renderModal(<ImportClaudeAuthModal onClose={vi.fn()} onSuccess={vi.fn()} />);
expect(c.querySelector("*")).not.toBeNull();
});
it("ImportGeminiAuthModal mounts standalone", () => {
const c = renderModal(<ImportGeminiAuthModal onClose={vi.fn()} onSuccess={vi.fn()} />);
expect(c.querySelector("*")).not.toBeNull();
});
});