mirror of
https://github.com/decolua/9router.git
synced 2026-05-08 12:01:28 +00:00
- Introduce default MITM router base URL and update related components to handle it.
- Add input for MITM router base URL in MitmServerCard component.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "9router-app",
|
||||
"version": "0.3.72",
|
||||
"version": "0.3.73",
|
||||
"description": "9Router web dashboard",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -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 }
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* API Key selector (only when stopped) */}
|
||||
{!isRunning && (
|
||||
{/* Base URL + API Key — same row pattern as Claude Code / cli-tools */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-text-muted shrink-0">API Key</span>
|
||||
{apiKeys?.length > 0 ? (
|
||||
<select
|
||||
value={selectedApiKey}
|
||||
onChange={(e) => setSelectedApiKey(e.target.value)}
|
||||
className="flex-1 px-2 py-1 bg-surface rounded text-xs border border-border focus:outline-none focus:ring-1 focus:ring-primary/50"
|
||||
>
|
||||
{apiKeys.map((key) => <option key={key.id} value={key.key}>{key.key}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<span className="text-xs text-text-muted">
|
||||
{cloudEnabled ? "No API keys — create one in Keys page" : "sk_9router (default)"}
|
||||
</span>
|
||||
)}
|
||||
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right">9Router Base URL</span>
|
||||
<span className="material-symbols-outlined text-text-muted text-[14px]">arrow_forward</span>
|
||||
<input
|
||||
type="text"
|
||||
value={mitmRouterBaseUrl}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!isRunning && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right">API Key</span>
|
||||
<span className="material-symbols-outlined text-text-muted text-[14px]">arrow_forward</span>
|
||||
{apiKeys?.length > 0 ? (
|
||||
<select
|
||||
value={selectedApiKey}
|
||||
onChange={(e) => setSelectedApiKey(e.target.value)}
|
||||
className="flex-1 min-w-0 px-2 py-1.5 bg-surface rounded text-xs border border-border text-text-main focus:outline-none focus:ring-1 focus:ring-primary/50"
|
||||
>
|
||||
{apiKeys.map((key) => (
|
||||
<option key={key.id} value={key.key}>
|
||||
{key.key}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<span className="flex-1 px-2 py-1.5 text-xs text-text-muted">
|
||||
{cloudEnabled ? "No API keys — create one in Keys page" : "sk_9router (default)"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-2 flex-wrap" data-i18n-skip="true">
|
||||
|
||||
@@ -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 (
|
||||
<Modal isOpen={isOpen} title="Edit Connection" onClose={onClose}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Input
|
||||
label="Name"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder={isOAuth ? "Account name" : "Production Key"}
|
||||
/>
|
||||
{isOAuth && connection.email && (
|
||||
<div className="bg-sidebar/50 p-3 rounded-lg">
|
||||
<p className="text-sm text-text-muted mb-1">Email</p>
|
||||
<p className="font-medium">{connection.email}</p>
|
||||
</div>
|
||||
)}
|
||||
<Input
|
||||
label="Priority"
|
||||
type="number"
|
||||
value={formData.priority}
|
||||
onChange={(e) => setFormData({ ...formData, priority: Number.parseInt(e.target.value) || 1 })}
|
||||
/>
|
||||
|
||||
{!isOAuth && (
|
||||
<>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
label="API Key"
|
||||
type="password"
|
||||
value={formData.apiKey}
|
||||
onChange={(e) => setFormData({ ...formData, apiKey: e.target.value })}
|
||||
placeholder="Enter new API key"
|
||||
hint="Leave blank to keep the current API key."
|
||||
className="flex-1"
|
||||
/>
|
||||
<div className="pt-6">
|
||||
<Button onClick={handleValidate} disabled={!formData.apiKey || validating || saving} variant="secondary">
|
||||
{validating ? "Checking..." : "Check"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{validationResult && (
|
||||
<Badge variant={validationResult === "success" ? "success" : "error"}>
|
||||
{validationResult === "success" ? "Valid" : "Invalid"}
|
||||
</Badge>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Test Connection */}
|
||||
{!isCompatible && (
|
||||
<div className="flex items-center gap-3">
|
||||
<Button onClick={handleTest} variant="secondary" disabled={testing}>
|
||||
{testing ? "Testing..." : "Test Connection"}
|
||||
</Button>
|
||||
{testResult && (
|
||||
<Badge variant={testResult === "success" ? "success" : "error"}>
|
||||
{testResult === "success" ? "Valid" : "Failed"}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleSubmit} fullWidth disabled={saving}>{saving ? "Saving..." : "Save"}</Button>
|
||||
<Button onClick={onClose} variant="ghost" fullWidth>Cancel</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
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: "",
|
||||
|
||||
@@ -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 (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full table-fixed">
|
||||
<table className="w-full table-fixed text-left">
|
||||
<colgroup>
|
||||
<col className="w-[30%]" /> {/* Model Name */}
|
||||
<col className="w-[45%]" /> {/* 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 */}
|
||||
<td className="py-2 px-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs">{colors.emoji}</span>
|
||||
<span className="text-sm font-medium text-text-primary">{quota.name}</span>
|
||||
<td className={cellPad}>
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span className="text-[10px] shrink-0">{colors.emoji}</span>
|
||||
<span className={`${nameText} font-medium text-text-primary truncate`}>
|
||||
{quota.name}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Limit (Progress + Numbers) */}
|
||||
<td className="py-2 px-3">
|
||||
<div className="space-y-1.5">
|
||||
<td className={cellPad}>
|
||||
<div className={compact ? "space-y-1" : "space-y-1.5"}>
|
||||
{/* Progress bar - always show with border for visibility */}
|
||||
<div className={`h-1.5 rounded-full overflow-hidden border ${colors.bgLight} ${
|
||||
<div className={`${compact ? "h-1" : "h-1.5"} rounded-full overflow-hidden border ${colors.bgLight} ${
|
||||
remaining === 0 ? 'border-black/10 dark:border-white/10' : 'border-transparent'
|
||||
}`}>
|
||||
<div
|
||||
@@ -120,7 +127,7 @@ export default function QuotaTable({ quotas = [] }) {
|
||||
</div>
|
||||
|
||||
{/* Numbers */}
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<div className={`flex items-center justify-between ${compact ? "text-[10px]" : "text-xs"}`}>
|
||||
<span className="text-text-muted">
|
||||
{quota.used.toLocaleString()} / {quota.total > 0 ? quota.total.toLocaleString() : "∞"}
|
||||
</span>
|
||||
@@ -132,22 +139,22 @@ export default function QuotaTable({ quotas = [] }) {
|
||||
</td>
|
||||
|
||||
{/* Reset Time */}
|
||||
<td className="py-2 px-3">
|
||||
<td className={cellPad}>
|
||||
{countdown !== "-" || resetDisplay ? (
|
||||
<div className="space-y-0.5">
|
||||
{countdown !== "-" && (
|
||||
<div className="text-sm text-text-primary font-medium">
|
||||
<div className={`${resetPrimary} text-text-primary font-medium`}>
|
||||
in {countdown}
|
||||
</div>
|
||||
)}
|
||||
{resetDisplay && (
|
||||
<div className="text-xs text-text-muted">
|
||||
<div className={`${resetSecondary} text-text-muted`}>
|
||||
{resetDisplay}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-text-muted italic">N/A</div>
|
||||
<div className={`${resetPrimary} text-text-muted italic`}>N/A</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Provider Cards Grid */}
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Provider cards: 2 columns, compact */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{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 (
|
||||
<Card key={conn.id} padding="none">
|
||||
<div className="p-6 border-b border-black/10 dark:border-white/10">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg flex items-center justify-center overflow-hidden">
|
||||
<Card
|
||||
key={conn.id}
|
||||
padding="none"
|
||||
className={`min-w-0 ${isInactive ? "opacity-60" : ""}`}
|
||||
>
|
||||
<div className="px-4 py-3 border-b border-black/10 dark:border-white/10">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="w-8 h-8 shrink-0 rounded-md flex items-center justify-center overflow-hidden">
|
||||
<ProviderIcon
|
||||
src={`/providers/${conn.provider}.png`}
|
||||
alt={conn.provider}
|
||||
size={40}
|
||||
size={32}
|
||||
className="object-contain"
|
||||
fallbackText={
|
||||
conn.provider?.slice(0, 2).toUpperCase() || "PR"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-text-primary capitalize">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-semibold text-text-primary capitalize truncate">
|
||||
{conn.provider}
|
||||
</h3>
|
||||
{conn.name && (
|
||||
<p className="text-sm text-text-muted">{conn.name}</p>
|
||||
<p className="text-xs text-text-muted truncate">
|
||||
{conn.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => refreshProvider(conn.id, conn.provider)}
|
||||
disabled={isLoading}
|
||||
className="p-2 rounded-lg hover:bg-black/5 dark:hover:bg-white/5 transition-colors disabled:opacity-50"
|
||||
title="Refresh quota"
|
||||
>
|
||||
<span
|
||||
className={`material-symbols-outlined text-[20px] text-text-muted ${isLoading ? "animate-spin" : ""}`}
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => refreshProvider(conn.id, conn.provider)}
|
||||
disabled={isLoading || rowBusy}
|
||||
className="p-1.5 rounded-lg hover:bg-black/5 dark:hover:bg-white/5 transition-colors disabled:opacity-50"
|
||||
title="Refresh quota"
|
||||
>
|
||||
refresh
|
||||
</span>
|
||||
</button>
|
||||
<span
|
||||
className={`material-symbols-outlined text-[18px] text-text-muted ${isLoading ? "animate-spin" : ""}`}
|
||||
>
|
||||
refresh
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedConnection(conn);
|
||||
setShowEditModal(true);
|
||||
}}
|
||||
disabled={rowBusy}
|
||||
className="p-1.5 rounded-lg hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-primary transition-colors disabled:opacity-50"
|
||||
title="Edit connection"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">
|
||||
edit
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDeleteConnection(conn.id)}
|
||||
disabled={rowBusy}
|
||||
className="p-1.5 rounded-lg hover:bg-red-500/10 text-red-500 transition-colors disabled:opacity-50"
|
||||
title="Delete connection"
|
||||
>
|
||||
<span
|
||||
className={`material-symbols-outlined text-[18px] ${deletingId === conn.id ? "animate-pulse" : ""}`}
|
||||
>
|
||||
delete
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
className="inline-flex items-center pl-0.5"
|
||||
title={
|
||||
(conn.isActive ?? true)
|
||||
? "Disable connection"
|
||||
: "Enable connection"
|
||||
}
|
||||
>
|
||||
<Toggle
|
||||
size="sm"
|
||||
checked={conn.isActive ?? true}
|
||||
disabled={rowBusy}
|
||||
onChange={(nextActive) =>
|
||||
handleToggleConnectionActive(conn.id, nextActive)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<div className="px-3 py-3">
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-text-muted">
|
||||
<span className="material-symbols-outlined text-[32px] animate-spin">
|
||||
<div className="text-center py-5 text-text-muted">
|
||||
<span className="material-symbols-outlined text-[28px] animate-spin">
|
||||
progress_activity
|
||||
</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-center py-8">
|
||||
<span className="material-symbols-outlined text-[32px] text-red-500">
|
||||
<div className="text-center py-5">
|
||||
<span className="material-symbols-outlined text-[28px] text-red-500">
|
||||
error
|
||||
</span>
|
||||
<p className="mt-2 text-sm text-text-muted">{error}</p>
|
||||
<p className="mt-1.5 text-xs text-text-muted">{error}</p>
|
||||
</div>
|
||||
) : quota?.message ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-sm text-text-muted">{quota.message}</p>
|
||||
<div className="text-center py-5">
|
||||
<p className="text-xs text-text-muted">{quota.message}</p>
|
||||
</div>
|
||||
) : (
|
||||
<QuotaTable quotas={quota?.quotas} />
|
||||
<QuotaTable quotas={quota?.quotas} compact />
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<EditConnectionModal
|
||||
isOpen={showEditModal}
|
||||
connection={selectedConnection}
|
||||
proxyPools={proxyPools}
|
||||
onSave={handleUpdateConnection}
|
||||
onClose={() => {
|
||||
setShowEditModal(false);
|
||||
setSelectedConnection(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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: {},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
204
src/shared/components/EditConnectionModal.js
Normal file
204
src/shared/components/EditConnectionModal.js
Normal file
@@ -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 (
|
||||
<Modal isOpen={isOpen} title="Edit Connection" onClose={onClose}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Input
|
||||
label="Name"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder={isOAuth ? "Account name" : "Production Key"}
|
||||
/>
|
||||
{isOAuth && connection.email && (
|
||||
<div className="bg-sidebar/50 p-3 rounded-lg">
|
||||
<p className="text-sm text-text-muted mb-1">Email</p>
|
||||
<p className="font-medium">{connection.email}</p>
|
||||
</div>
|
||||
)}
|
||||
<Input
|
||||
label="Priority"
|
||||
type="number"
|
||||
value={formData.priority}
|
||||
onChange={(e) => setFormData({ ...formData, priority: Number.parseInt(e.target.value, 10) || 1 })}
|
||||
/>
|
||||
|
||||
{!isOAuth && (
|
||||
<>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
label="API Key"
|
||||
type="password"
|
||||
value={formData.apiKey}
|
||||
onChange={(e) => setFormData({ ...formData, apiKey: e.target.value })}
|
||||
placeholder="Enter new API key"
|
||||
hint="Leave blank to keep the current API key."
|
||||
className="flex-1"
|
||||
/>
|
||||
<div className="pt-6">
|
||||
<Button onClick={handleValidate} disabled={!formData.apiKey || validating || saving} variant="secondary">
|
||||
{validating ? "Checking..." : "Check"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{validationResult && (
|
||||
<Badge variant={validationResult === "success" ? "success" : "error"}>
|
||||
{validationResult === "success" ? "Valid" : "Invalid"}
|
||||
</Badge>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!isCompatible && (
|
||||
<div className="flex items-center gap-3">
|
||||
<Button onClick={handleTest} variant="secondary" disabled={testing}>
|
||||
{testing ? "Testing..." : "Test Connection"}
|
||||
</Button>
|
||||
{testResult && (
|
||||
<Badge variant={testResult === "success" ? "success" : "error"}>
|
||||
{testResult === "success" ? "Valid" : "Failed"}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleSubmit} fullWidth disabled={saving}>{saving ? "Saving..." : "Save"}</Button>
|
||||
<Button onClick={onClose} variant="ghost" fullWidth>Cancel</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user