diff --git a/package.json b/package.json index bd1a4f8..0274dc1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "9router-app", - "version": "0.3.72", + "version": "0.3.73", "description": "9Router web dashboard", "private": true, "scripts": { diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/MitmServerCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/MitmServerCard.js index 466fe0c..020cb86 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/MitmServerCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/MitmServerCard.js @@ -3,6 +3,8 @@ import { useState, useEffect } from "react"; import { Card, Button, Badge, Input } from "@/shared/components"; +const DEFAULT_MITM_ROUTER_BASE = "http://localhost:20128"; + /** * Shared MITM infrastructure card — manages SSL cert + server start/stop. * DNS per-tool is handled separately in MitmToolCard. @@ -15,6 +17,7 @@ export default function MitmServerCard({ apiKeys, cloudEnabled, onStatusChange } const [selectedApiKey, setSelectedApiKey] = useState(""); const [pendingAction, setPendingAction] = useState(null); const [modalError, setModalError] = useState(null); + const [mitmRouterBaseUrl, setMitmRouterBaseUrl] = useState(DEFAULT_MITM_ROUTER_BASE); const isWindows = typeof navigator !== "undefined" && navigator.userAgent?.includes("Windows"); const isAdmin = status?.isAdmin !== false; @@ -35,6 +38,9 @@ export default function MitmServerCard({ apiKeys, cloudEnabled, onStatusChange } if (res.ok) { const data = await res.json(); setStatus(data); + if (data.mitmRouterBaseUrl) { + setMitmRouterBaseUrl(data.mitmRouterBaseUrl); + } onStatusChange?.(data); } } catch { @@ -68,7 +74,11 @@ export default function MitmServerCard({ apiKeys, cloudEnabled, onStatusChange } await fetch("/api/cli-tools/antigravity-mitm", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ apiKey: keyToUse, sudoPassword: password }), + body: JSON.stringify({ + apiKey: keyToUse, + sudoPassword: password, + mitmRouterBaseUrl: mitmRouterBaseUrl.trim() || DEFAULT_MITM_ROUTER_BASE, + }), }); } else { await fetch("/api/cli-tools/antigravity-mitm", { @@ -137,25 +147,44 @@ export default function MitmServerCard({ apiKeys, cloudEnabled, onStatusChange }

- {/* API Key selector (only when stopped) */} - {!isRunning && ( + {/* Base URL + API Key — same row pattern as Claude Code / cli-tools */} +
- API Key - {apiKeys?.length > 0 ? ( - - ) : ( - - {cloudEnabled ? "No API keys — create one in Keys page" : "sk_9router (default)"} - - )} + 9Router Base URL + arrow_forward + setMitmRouterBaseUrl(e.target.value)} + placeholder={DEFAULT_MITM_ROUTER_BASE} + disabled={isRunning} + className="flex-1 min-w-0 px-2 py-1.5 bg-surface rounded border border-border text-xs text-text-main focus:outline-none focus:ring-1 focus:ring-primary/50 disabled:opacity-50" + />
- )} + {!isRunning && ( +
+ API Key + arrow_forward + {apiKeys?.length > 0 ? ( + + ) : ( + + {cloudEnabled ? "No API keys — create one in Keys page" : "sk_9router (default)"} + + )} +
+ )} +
{/* Action buttons */}
diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.js b/src/app/(dashboard)/dashboard/providers/[id]/page.js index 5d6bb26..36d6cca 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.js @@ -5,7 +5,7 @@ import PropTypes from "prop-types"; import { useParams, useRouter } from "next/navigation"; import Link from "next/link"; import Image from "next/image"; -import { Card, Button, Badge, Input, Modal, CardSkeleton, OAuthModal, KiroOAuthWrapper, CursorAuthModal, IFlowCookieModal, GitLabAuthModal, Toggle, Select } from "@/shared/components"; +import { Card, Button, Badge, Input, Modal, CardSkeleton, OAuthModal, KiroOAuthWrapper, CursorAuthModal, IFlowCookieModal, GitLabAuthModal, Toggle, Select, EditConnectionModal } from "@/shared/components"; import { OAUTH_PROVIDERS, APIKEY_PROVIDERS, FREE_PROVIDERS, FREE_TIER_PROVIDERS, getProviderAlias, isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers"; import { getModelsByProviderId } from "@/shared/constants/models"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; @@ -1873,199 +1873,6 @@ AddApiKeyModal.propTypes = { onClose: PropTypes.func.isRequired, }; -function EditConnectionModal({ isOpen, connection, proxyPools, onSave, onClose }) { - const [formData, setFormData] = useState({ - name: "", - priority: 1, - apiKey: "", - }); - const [testing, setTesting] = useState(false); - const [testResult, setTestResult] = useState(null); - const [validating, setValidating] = useState(false); - const [validationResult, setValidationResult] = useState(null); - const [saving, setSaving] = useState(false); - - useEffect(() => { - if (connection) { - setFormData({ - name: connection.name || "", - priority: connection.priority || 1, - apiKey: "", - }); - setTestResult(null); - setValidationResult(null); - } - }, [connection]); - - const handleTest = async () => { - if (!connection?.provider) return; - setTesting(true); - setTestResult(null); - try { - const res = await fetch(`/api/providers/${connection.id}/test`, { method: "POST" }); - const data = await res.json(); - setTestResult(data.valid ? "success" : "failed"); - } catch { - setTestResult("failed"); - } finally { - setTesting(false); - } - }; - - const handleValidate = async () => { - if (!connection?.provider || !formData.apiKey) return; - setValidating(true); - setValidationResult(null); - try { - const res = await fetch("/api/providers/validate", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ provider: connection.provider, apiKey: formData.apiKey }), - }); - const data = await res.json(); - setValidationResult(data.valid ? "success" : "failed"); - } catch { - setValidationResult("failed"); - } finally { - setValidating(false); - } - }; - - const handleSubmit = async () => { - setSaving(true); - try { - const updates = { - name: formData.name, - priority: formData.priority, - }; - if (!isOAuth && formData.apiKey) { - updates.apiKey = formData.apiKey; - let isValid = validationResult === "success"; - if (!isValid) { - try { - setValidating(true); - setValidationResult(null); - const res = await fetch("/api/providers/validate", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ provider: connection.provider, apiKey: formData.apiKey }), - }); - const data = await res.json(); - isValid = !!data.valid; - setValidationResult(isValid ? "success" : "failed"); - } catch { - setValidationResult("failed"); - } finally { - setValidating(false); - } - } - if (isValid) { - updates.testStatus = "active"; - updates.lastError = null; - updates.lastErrorAt = null; - } - } - await onSave(updates); - } finally { - setSaving(false); - } - }; - - if (!connection) return null; - - const isOAuth = connection.authType === "oauth"; - const isCompatible = isOpenAICompatibleProvider(connection.provider) || isAnthropicCompatibleProvider(connection.provider); - - return ( - -
- setFormData({ ...formData, name: e.target.value })} - placeholder={isOAuth ? "Account name" : "Production Key"} - /> - {isOAuth && connection.email && ( -
-

Email

-

{connection.email}

-
- )} - setFormData({ ...formData, priority: Number.parseInt(e.target.value) || 1 })} - /> - - {!isOAuth && ( - <> -
- setFormData({ ...formData, apiKey: e.target.value })} - placeholder="Enter new API key" - hint="Leave blank to keep the current API key." - className="flex-1" - /> -
- -
-
- {validationResult && ( - - {validationResult === "success" ? "Valid" : "Invalid"} - - )} - - )} - - {/* Test Connection */} - {!isCompatible && ( -
- - {testResult && ( - - {testResult === "success" ? "Valid" : "Failed"} - - )} -
- )} - -
- - -
-
-
- ); -} - -EditConnectionModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - connection: PropTypes.shape({ - id: PropTypes.string, - name: PropTypes.string, - email: PropTypes.string, - priority: PropTypes.number, - authType: PropTypes.string, - provider: PropTypes.string, - providerSpecificData: PropTypes.object, - }), - proxyPools: PropTypes.arrayOf(PropTypes.shape({ - id: PropTypes.string, - name: PropTypes.string, - })), - onSave: PropTypes.func.isRequired, - onClose: PropTypes.func.isRequired, -}; - function EditCompatibleNodeModal({ isOpen, node, onSave, onClose, isAnthropic }) { const [formData, setFormData] = useState({ name: "", diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaTable.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaTable.js index e15d5dd..8fc2ecf 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaTable.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaTable.js @@ -70,14 +70,19 @@ function getColorClasses(remainingPercentage) { /** * Quota Table Component - Table-based display for quota data */ -export default function QuotaTable({ quotas = [] }) { +export default function QuotaTable({ quotas = [], compact = false }) { if (!quotas || quotas.length === 0) { return null; } + const cellPad = compact ? "py-1.5 px-2" : "py-2 px-3"; + const nameText = compact ? "text-xs" : "text-sm"; + const resetPrimary = compact ? "text-xs" : "text-sm"; + const resetSecondary = compact ? "text-[10px] leading-tight" : "text-xs"; + return (
- +
{/* Model Name */} {/* Limit Progress */} @@ -99,18 +104,20 @@ export default function QuotaTable({ quotas = [] }) { className="border-b border-black/5 dark:border-white/5 hover:bg-black/[0.02] dark:hover:bg-white/[0.02] transition-colors" > {/* Model Name with Status Emoji */} - {/* Limit (Progress + Numbers) */} - {/* Reset Time */} - diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js index 1c6324d..3f0dc59 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js @@ -2,11 +2,12 @@ import { useState, useEffect, useCallback, useRef } from "react"; import ProviderIcon from "@/shared/components/ProviderIcon"; -import ProviderLimitCard from "./ProviderLimitCard"; import QuotaTable from "./QuotaTable"; +import Toggle from "@/shared/components/Toggle"; import { parseQuotaData, calculatePercentage } from "./utils"; import Card from "@/shared/components/Card"; import Button from "@/shared/components/Button"; +import { EditConnectionModal } from "@/shared/components"; import { USAGE_SUPPORTED_PROVIDERS } from "@/shared/constants/providers"; const REFRESH_INTERVAL_MS = 60000; // 60 seconds @@ -21,6 +22,11 @@ export default function ProviderLimits() { const [refreshingAll, setRefreshingAll] = useState(false); const [countdown, setCountdown] = useState(60); const [connectionsLoading, setConnectionsLoading] = useState(true); + const [deletingId, setDeletingId] = useState(null); + const [togglingId, setTogglingId] = useState(null); + const [showEditModal, setShowEditModal] = useState(false); + const [selectedConnection, setSelectedConnection] = useState(null); + const [proxyPools, setProxyPools] = useState([]); const intervalRef = useRef(null); const countdownRef = useRef(null); @@ -123,6 +129,97 @@ export default function ProviderLimits() { [fetchQuota], ); + const handleDeleteConnection = useCallback(async (id) => { + if (!confirm("Delete this connection?")) return; + setDeletingId(id); + try { + const res = await fetch(`/api/providers/${id}`, { method: "DELETE" }); + if (res.ok) { + setConnections((prev) => prev.filter((c) => c.id !== id)); + setQuotaData((prev) => { + const next = { ...prev }; + delete next[id]; + return next; + }); + setLoading((prev) => { + const next = { ...prev }; + delete next[id]; + return next; + }); + setErrors((prev) => { + const next = { ...prev }; + delete next[id]; + return next; + }); + } + } catch (error) { + console.error("Error deleting connection:", error); + } finally { + setDeletingId(null); + } + }, []); + + const handleToggleConnectionActive = useCallback(async (id, isActive) => { + setTogglingId(id); + try { + const res = await fetch(`/api/providers/${id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ isActive }), + }); + if (res.ok) { + setConnections((prev) => + prev.map((c) => (c.id === id ? { ...c, isActive } : c)), + ); + } + } catch (error) { + console.error("Error updating connection status:", error); + } finally { + setTogglingId(null); + } + }, []); + + const handleUpdateConnection = useCallback( + async (formData) => { + if (!selectedConnection?.id) return; + const connectionId = selectedConnection.id; + const provider = selectedConnection.provider; + try { + const res = await fetch(`/api/providers/${connectionId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(formData), + }); + if (res.ok) { + await fetchConnections(); + setShowEditModal(false); + setSelectedConnection(null); + if (USAGE_SUPPORTED_PROVIDERS.includes(provider)) { + await fetchQuota(connectionId, provider); + } + } + } catch (error) { + console.error("Error saving connection:", error); + } + }, + [selectedConnection, fetchConnections, fetchQuota], + ); + + useEffect(() => { + let cancelled = false; + fetch("/api/proxy-pools?isActive=true", { cache: "no-store" }) + .then((res) => res.json()) + .then((data) => { + if (!cancelled && data?.proxyPools) { + setProxyPools(data.proxyPools); + } + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, []); + // Refresh all providers const refreshAll = useCallback(async () => { if (refreshingAll) return; @@ -358,81 +455,148 @@ export default function ProviderLimits() { - {/* Provider Cards Grid */} -
+ {/* Provider cards: 2 columns, compact */} +
{sortedConnections.map((conn) => { const quota = quotaData[conn.id]; const isLoading = loading[conn.id]; const error = errors[conn.id]; // Use table layout for all providers + const isInactive = conn.isActive === false; + const rowBusy = deletingId === conn.id || togglingId === conn.id; + return ( - -
-
-
-
+ +
+
+
+
-
-

+
+

{conn.provider}

{conn.name && ( -

{conn.name}

+

+ {conn.name} +

)}

- + + refresh + + + + +
+ + handleToggleConnectionActive(conn.id, nextActive) + } + /> +
+
-
+
{isLoading ? ( -
- +
+ progress_activity
) : error ? ( -
- +
+ error -

{error}

+

{error}

) : quota?.message ? ( -
-

{quota.message}

+
+

{quota.message}

) : ( - + )}
); })}
+ + { + setShowEditModal(false); + setSelectedConnection(null); + }} + />
); } diff --git a/src/app/api/cli-tools/antigravity-mitm/route.js b/src/app/api/cli-tools/antigravity-mitm/route.js index 45dbcf9..9c758c7 100644 --- a/src/app/api/cli-tools/antigravity-mitm/route.js +++ b/src/app/api/cli-tools/antigravity-mitm/route.js @@ -17,6 +17,25 @@ import { getSettings, updateSettings } from "@/lib/localDb"; initDbHooks(getSettings, updateSettings); +const DEFAULT_MITM_ROUTER_BASE = "http://localhost:20128"; + +function normalizeMitmRouterBaseUrlInput(input) { + if (input == null || String(input).trim() === "") { + return DEFAULT_MITM_ROUTER_BASE; + } + const t = String(input).trim().replace(/\/+$/, ""); + let u; + try { + u = new URL(t); + } catch { + throw new Error("Invalid MITM router URL"); + } + if (u.protocol !== "http:" && u.protocol !== "https:") { + throw new Error("MITM router URL must use http or https"); + } + return t; +} + const isWin = process.platform === "win32"; function getPassword(provided) { @@ -37,6 +56,7 @@ function checkIsAdmin() { export async function GET() { try { const status = await getMitmStatus(); + const settings = await getSettings(); return NextResponse.json({ running: status.running, pid: status.pid || null, @@ -45,6 +65,9 @@ export async function GET() { dnsStatus: status.dnsStatus || {}, hasCachedPassword: !!getCachedPassword() || !!(await loadEncryptedPassword()), isAdmin: checkIsAdmin(), + mitmRouterBaseUrl: + (settings.mitmRouterBaseUrl && String(settings.mitmRouterBaseUrl).trim()) || + DEFAULT_MITM_ROUTER_BASE, }); } catch (error) { console.log("Error getting MITM status:", error.message); @@ -55,7 +78,7 @@ export async function GET() { // POST - Start MITM server (cert + server, no DNS) export async function POST(request) { try { - const { apiKey, sudoPassword } = await request.json(); + const { apiKey, sudoPassword, mitmRouterBaseUrl } = await request.json(); const pwd = getPassword(sudoPassword) || await loadEncryptedPassword() || ""; if (!apiKey || (!isWin && !pwd)) { @@ -65,6 +88,18 @@ export async function POST(request) { ); } + if (mitmRouterBaseUrl !== undefined && mitmRouterBaseUrl !== null) { + try { + const normalized = normalizeMitmRouterBaseUrlInput(mitmRouterBaseUrl); + await updateSettings({ mitmRouterBaseUrl: normalized }); + } catch (e) { + return NextResponse.json( + { error: e.message || "Invalid MITM router URL" }, + { status: 400 }, + ); + } + } + const result = await startServer(apiKey, pwd); if (!isWin) setCachedPassword(pwd); diff --git a/src/lib/localDb.js b/src/lib/localDb.js index f911a14..00983b6 100644 --- a/src/lib/localDb.js +++ b/src/lib/localDb.js @@ -6,6 +6,8 @@ import os from "node:os"; import fs from "node:fs"; import lockfile from "proper-lockfile"; +const DEFAULT_MITM_ROUTER_BASE = "http://localhost:20128"; + const isCloud = typeof caches !== 'undefined' || typeof caches === 'object'; // Get app name - fixed constant to avoid Windows path issues in standalone build @@ -65,7 +67,8 @@ const defaultData = { observabilityMaxJsonSize: 1024, outboundProxyEnabled: false, outboundProxyUrl: "", - outboundNoProxy: "" + outboundNoProxy: "", + mitmRouterBaseUrl: DEFAULT_MITM_ROUTER_BASE, }, pricing: {} // NEW: pricing configuration }; @@ -101,6 +104,7 @@ function cloneDefaultData() { outboundProxyEnabled: false, outboundProxyUrl: "", outboundNoProxy: "", + mitmRouterBaseUrl: DEFAULT_MITM_ROUTER_BASE, }, pricing: {}, }; diff --git a/src/mitm/handlers/base.js b/src/mitm/handlers/base.js index 558b209..1268d16 100644 --- a/src/mitm/handlers/base.js +++ b/src/mitm/handlers/base.js @@ -1,6 +1,9 @@ const { log, err } = require("../logger"); -const ROUTER_BASE = "http://localhost:20128"; +const DEFAULT_LOCAL_ROUTER = "http://localhost:20128"; +const ROUTER_BASE = String(process.env.MITM_ROUTER_BASE || DEFAULT_LOCAL_ROUTER) + .trim() + .replace(/\/+$/, "") || DEFAULT_LOCAL_ROUTER; const API_KEY = process.env.ROUTER_API_KEY; /** diff --git a/src/mitm/manager.js b/src/mitm/manager.js index 31c362f..897145a 100644 --- a/src/mitm/manager.js +++ b/src/mitm/manager.js @@ -15,6 +15,27 @@ const { isCertExpired } = require("./cert/rootCA"); const { MITM_DIR } = require("./paths"); const { log, err } = require("./logger"); +const DEFAULT_MITM_ROUTER_BASE = "http://localhost:20128"; + +function shellQuoteSingle(str) { + if (str == null || str === "") return "''"; + return `'${String(str).replace(/'/g, "'\\''")}'`; +} + +async function resolveMitmRouterBaseUrl() { + if (!_getSettings) return DEFAULT_MITM_ROUTER_BASE; + try { + const s = await _getSettings(); + const raw = s && s.mitmRouterBaseUrl != null ? String(s.mitmRouterBaseUrl).trim() : ""; + if (!raw) return DEFAULT_MITM_ROUTER_BASE; + const u = new URL(raw); + if (u.protocol !== "http:" && u.protocol !== "https:") return DEFAULT_MITM_ROUTER_BASE; + return raw.replace(/\/+$/, ""); + } catch { + return DEFAULT_MITM_ROUTER_BASE; + } +} + const MITM_PORT = 443; const MITM_WIN_NODE_PORT = 8443; const PID_FILE = path.join(MITM_DIR, ".mitm.pid"); @@ -427,7 +448,8 @@ async function startServer(apiKey, sudoPassword) { } // Step 2: Spawn server (Root CA already installed in Step 1.5) - log("🚀 Starting server..."); + const mitmRouterBase = await resolveMitmRouterBaseUrl(); + log(`🚀 Starting server... (router: ${mitmRouterBase})`); if (IS_WIN) { // Kill any process using port 443 before spawning try { @@ -444,7 +466,12 @@ async function startServer(apiKey, sudoPassword) { detached: false, windowsHide: true, stdio: ["ignore", "pipe", "pipe"], - env: { ...process.env, ROUTER_API_KEY: apiKey, NODE_ENV: "production" }, + env: { + ...process.env, + ROUTER_API_KEY: apiKey, + NODE_ENV: "production", + MITM_ROUTER_BASE: mitmRouterBase, + }, } ); @@ -452,7 +479,14 @@ async function startServer(apiKey, sudoPassword) { } else if (isSudoAvailable()) { // Pass HOME explicitly so os.homedir() resolves to the unprivileged user's home // instead of /root when sudo resets the environment. - const inlineCmd = `HOME='${os.homedir()}' ROUTER_API_KEY='${apiKey}' NODE_ENV='production' '${process.execPath}' '${SERVER_PATH}'`; + const inlineCmd = [ + `HOME=${shellQuoteSingle(os.homedir())}`, + `ROUTER_API_KEY=${shellQuoteSingle(apiKey)}`, + `MITM_ROUTER_BASE=${shellQuoteSingle(mitmRouterBase)}`, + "NODE_ENV=production", + shellQuoteSingle(process.execPath), + shellQuoteSingle(SERVER_PATH), + ].join(" "); serverProcess = spawn( "sudo", ["-S", "-E", "sh", "-c", inlineCmd], { detached: false, stdio: ["pipe", "pipe", "pipe"] } @@ -464,7 +498,12 @@ async function startServer(apiKey, sudoPassword) { serverProcess = spawn(process.execPath, [SERVER_PATH], { detached: false, stdio: ["ignore", "pipe", "pipe"], - env: { ...process.env, ROUTER_API_KEY: apiKey, NODE_ENV: "production" }, + env: { + ...process.env, + ROUTER_API_KEY: apiKey, + NODE_ENV: "production", + MITM_ROUTER_BASE: mitmRouterBase, + }, }); } diff --git a/src/shared/components/EditConnectionModal.js b/src/shared/components/EditConnectionModal.js new file mode 100644 index 0000000..f9c8420 --- /dev/null +++ b/src/shared/components/EditConnectionModal.js @@ -0,0 +1,204 @@ +"use client"; + +import { useState, useEffect } from "react"; +import PropTypes from "prop-types"; +import Modal from "@/shared/components/Modal"; +import Input from "@/shared/components/Input"; +import Button from "@/shared/components/Button"; +import Badge from "@/shared/components/Badge"; +import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers"; + +export default function EditConnectionModal({ isOpen, connection, proxyPools, onSave, onClose }) { + const [formData, setFormData] = useState({ + name: "", + priority: 1, + apiKey: "", + }); + const [testing, setTesting] = useState(false); + const [testResult, setTestResult] = useState(null); + const [validating, setValidating] = useState(false); + const [validationResult, setValidationResult] = useState(null); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (connection) { + setFormData({ + name: connection.name || "", + priority: connection.priority || 1, + apiKey: "", + }); + setTestResult(null); + setValidationResult(null); + } + }, [connection]); + + const isOAuth = connection?.authType === "oauth"; + const isCompatible = connection + ? (isOpenAICompatibleProvider(connection.provider) || isAnthropicCompatibleProvider(connection.provider)) + : false; + + const handleTest = async () => { + if (!connection?.provider) return; + setTesting(true); + setTestResult(null); + try { + const res = await fetch(`/api/providers/${connection.id}/test`, { method: "POST" }); + const data = await res.json(); + setTestResult(data.valid ? "success" : "failed"); + } catch { + setTestResult("failed"); + } finally { + setTesting(false); + } + }; + + const handleValidate = async () => { + if (!connection?.provider || !formData.apiKey) return; + setValidating(true); + setValidationResult(null); + try { + const res = await fetch("/api/providers/validate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider: connection.provider, apiKey: formData.apiKey }), + }); + const data = await res.json(); + setValidationResult(data.valid ? "success" : "failed"); + } catch { + setValidationResult("failed"); + } finally { + setValidating(false); + } + }; + + const handleSubmit = async () => { + if (!connection) return; + setSaving(true); + try { + const updates = { + name: formData.name, + priority: formData.priority, + }; + if (!isOAuth && formData.apiKey) { + updates.apiKey = formData.apiKey; + let isValid = validationResult === "success"; + if (!isValid) { + try { + setValidating(true); + setValidationResult(null); + const res = await fetch("/api/providers/validate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider: connection.provider, apiKey: formData.apiKey }), + }); + const data = await res.json(); + isValid = !!data.valid; + setValidationResult(isValid ? "success" : "failed"); + } catch { + setValidationResult("failed"); + } finally { + setValidating(false); + } + } + if (isValid) { + updates.testStatus = "active"; + updates.lastError = null; + updates.lastErrorAt = null; + } + } + await onSave(updates); + } finally { + setSaving(false); + } + }; + + if (!connection) return null; + + return ( + +
+ setFormData({ ...formData, name: e.target.value })} + placeholder={isOAuth ? "Account name" : "Production Key"} + /> + {isOAuth && connection.email && ( +
+

Email

+

{connection.email}

+
+ )} + setFormData({ ...formData, priority: Number.parseInt(e.target.value, 10) || 1 })} + /> + + {!isOAuth && ( + <> +
+ setFormData({ ...formData, apiKey: e.target.value })} + placeholder="Enter new API key" + hint="Leave blank to keep the current API key." + className="flex-1" + /> +
+ +
+
+ {validationResult && ( + + {validationResult === "success" ? "Valid" : "Invalid"} + + )} + + )} + + {!isCompatible && ( +
+ + {testResult && ( + + {testResult === "success" ? "Valid" : "Failed"} + + )} +
+ )} + +
+ + +
+
+
+ ); +} + +EditConnectionModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + connection: PropTypes.shape({ + id: PropTypes.string, + name: PropTypes.string, + email: PropTypes.string, + priority: PropTypes.number, + authType: PropTypes.string, + provider: PropTypes.string, + providerSpecificData: PropTypes.object, + }), + proxyPools: PropTypes.arrayOf(PropTypes.shape({ + id: PropTypes.string, + name: PropTypes.string, + })), + onSave: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired, +}; diff --git a/src/shared/components/index.js b/src/shared/components/index.js index f8f3e50..9ed963c 100644 --- a/src/shared/components/index.js +++ b/src/shared/components/index.js @@ -27,6 +27,7 @@ export { default as KiroSocialOAuthModal } from "./KiroSocialOAuthModal"; export { default as CursorAuthModal } from "./CursorAuthModal"; export { default as IFlowCookieModal } from "./IFlowCookieModal"; export { default as GitLabAuthModal } from "./GitLabAuthModal"; +export { default as EditConnectionModal } from "./EditConnectionModal"; export { default as SegmentedControl } from "./SegmentedControl"; export { default as Tooltip } from "./Tooltip";
-
- {colors.emoji} - {quota.name} +
+
+ {colors.emoji} + + {quota.name} +
-
+
+
{/* Progress bar - always show with border for visibility */} -
{/* Numbers */} -
+
{quota.used.toLocaleString()} / {quota.total > 0 ? quota.total.toLocaleString() : "∞"} @@ -132,22 +139,22 @@ export default function QuotaTable({ quotas = [] }) {
+ {countdown !== "-" || resetDisplay ? (
{countdown !== "-" && ( -
+
in {countdown}
)} {resetDisplay && ( -
+
{resetDisplay}
)}
) : ( -
N/A
+
N/A
)}