Files
OmniRoute/src/shared/components/ProxyConfigModal.tsx
Diego Rodrigues de Sa e Souza b71716e179 fix(oauth): stop token-refresh invalidation loop + harden proxy resolution (#3692)
Integrated into release/v3.8.23
2026-06-12 02:51:40 -03:00

797 lines
29 KiB
TypeScript

"use client";
import { useState, useEffect, useMemo } from "react";
import { useTranslations } from "next-intl";
import Modal from "./Modal";
import Button from "./Button";
const ALL_PROXY_TYPES = [
{ value: "http", label: "HTTP" },
{ value: "https", label: "HTTPS" },
{ value: "socks5", label: "SOCKS5" },
];
// Build-time fallback (static deploys). The live value comes from GET /api/settings/proxies
// (server ENABLE_SOCKS5_PROXY) so a runtime Docker env is honoured — #3508.
// Default ON (opt-out) to match the server: only an explicit falsey value hides SOCKS5.
const BUILD_TIME_SOCKS5 = !["false", "0", "no", "off"].includes(
(process.env.NEXT_PUBLIC_ENABLE_SOCKS5_PROXY ?? "").trim().toLowerCase()
);
export function buildProxyTypes(socks5Enabled: boolean) {
return socks5Enabled ? ALL_PROXY_TYPES : ALL_PROXY_TYPES.filter((type) => type.value !== "socks5");
}
type ProxyConfigLevel = "global" | "provider" | "combo" | "key";
type ProxyRegistryItem = {
id: string;
name?: string;
type?: string;
host?: string;
port?: number | string;
username?: string | null;
password?: string | null;
source?: string | null;
};
type ProxyAssignmentItem = {
proxyId?: string | null;
scope?: string | null;
scopeId?: string | null;
};
type ProxyConfigModalProps = {
isOpen: boolean;
onClose: () => void;
level: ProxyConfigLevel;
levelId?: string;
levelLabel?: string;
onSaved?: () => void;
};
const DASHBOARD_CUSTOM_PROXY_SOURCE = "dashboard-custom";
const DASHBOARD_CUSTOM_PROXY_NOTES = "Created from the dashboard Custom proxy tab.";
function getAssignmentScope(level: ProxyConfigLevel) {
return level === "key" ? "account" : level;
}
function getAssignmentScopeId(level: ProxyConfigLevel, levelId?: string) {
return level === "global" ? null : levelId || null;
}
function normalizeScopeId(scopeId?: string | null) {
return !scopeId || scopeId === "__global__" ? null : scopeId;
}
function isSameScopeAssignment(
assignment: ProxyAssignmentItem,
scope: string,
scopeId: string | null
) {
return (
assignment.scope === scope && normalizeScopeId(assignment.scopeId) === normalizeScopeId(scopeId)
);
}
function getCustomProxyName(level: ProxyConfigLevel, levelId?: string, levelLabel?: string) {
const label = levelLabel || levelId || "";
const suffix = label ? ` (${label})` : "";
if (level === "global") return "Custom Global Proxy";
if (level === "key") return `Custom Account Proxy${suffix}`;
if (level === "combo") return `Custom Combo Proxy${suffix}`;
return `Custom Provider Proxy${suffix}`;
}
async function readJson(response: Response) {
return response.json().catch(() => ({}));
}
async function fetchAssignmentForScope(scope: string, scopeId: string | null) {
const params = new URLSearchParams({ scope });
if (scopeId) params.set("scopeId", scopeId);
const res = await fetch(`/api/settings/proxies/assignments?${params}`);
if (!res.ok) return null;
const payload = await readJson(res);
const items: ProxyAssignmentItem[] = Array.isArray(payload?.items) ? payload.items : [];
return items.find((item) => isSameScopeAssignment(item, scope, scopeId)) || items[0] || null;
}
async function fetchRegistryProxy(proxyId: string, cachedProxies: ProxyRegistryItem[]) {
const cached = cachedProxies.find((proxy) => proxy.id === proxyId);
if (cached) return cached;
const res = await fetch(`/api/settings/proxies?id=${encodeURIComponent(proxyId)}`);
if (!res.ok) return null;
return (await readJson(res)) as ProxyRegistryItem;
}
async function fetchProxyUsage(proxyId: string) {
const res = await fetch(`/api/settings/proxies?id=${encodeURIComponent(proxyId)}&whereUsed=1`);
if (!res.ok) return [];
const payload = await readJson(res);
const assignments: ProxyAssignmentItem[] = Array.isArray(payload?.assignments)
? payload.assignments
: [];
const seen = new Set<string>();
return assignments.filter((assignment) => {
const key = `${assignment.scope || ""}:${normalizeScopeId(assignment.scopeId) || ""}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
function isRedactedSecret(value?: string | null) {
return value === "***";
}
export default function ProxyConfigModal({
isOpen,
onClose,
level,
levelId,
levelLabel,
onSaved,
}: ProxyConfigModalProps) {
const t = useTranslations("proxyConfigModal");
const [mode, setMode] = useState("saved");
const [savedProxies, setSavedProxies] = useState<ProxyRegistryItem[]>([]);
const [selectedProxyId, setSelectedProxyId] = useState("");
const [socks5Enabled, setSocks5Enabled] = useState(BUILD_TIME_SOCKS5);
const proxyTypes = useMemo(() => buildProxyTypes(socks5Enabled), [socks5Enabled]);
const [proxyType, setProxyType] = useState("http");
const [host, setHost] = useState("");
const [port, setPort] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [showAuth, setShowAuth] = useState(false);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState(null);
const [loading, setLoading] = useState(true);
const [inheritedFrom, setInheritedFrom] = useState(null);
const [hasOwnProxy, setHasOwnProxy] = useState(false);
const [formError, setFormError] = useState(null);
const getDefaultPort = (type) => {
if (type === "socks5") return "1080";
if (type === "https") return "443";
return "8080";
};
// Load existing proxy config when modal opens
useEffect(() => {
if (!isOpen) return;
setTestResult(null);
setFormError(null);
setLoading(true);
const loadProxy = async () => {
try {
let hasSavedAssignment = false;
let registryItems: ProxyRegistryItem[] = [];
let runtimeSocks5 = BUILD_TIME_SOCKS5;
const registryRes = await fetch("/api/settings/proxies");
if (registryRes.ok) {
const registryPayload = await registryRes.json();
registryItems = Array.isArray(registryPayload?.items) ? registryPayload.items : [];
setSavedProxies(registryItems);
if (typeof registryPayload?.socks5Enabled === "boolean") {
runtimeSocks5 = registryPayload.socks5Enabled;
}
} else {
setSavedProxies([]);
}
setSocks5Enabled(runtimeSocks5);
const runtimeProxyTypes = buildProxyTypes(runtimeSocks5);
const scope = getAssignmentScope(level);
const assignmentParams = new URLSearchParams({ scope });
const scopeId = getAssignmentScopeId(level, levelId);
if (scopeId) {
assignmentParams.set("scopeId", scopeId);
}
const assignmentRes = await fetch(`/api/settings/proxies/assignments?${assignmentParams}`);
if (assignmentRes.ok) {
const assignmentPayload = await assignmentRes.json();
const items = Array.isArray(assignmentPayload?.items) ? assignmentPayload.items : [];
const target =
items.find((item) => isSameScopeAssignment(item, scope, scopeId)) || items[0];
if (target?.proxyId) {
setSelectedProxyId(target.proxyId);
setHasOwnProxy(true);
hasSavedAssignment = true;
const assignedProxy = registryItems.find((item) => item.id === target.proxyId);
if (assignedProxy?.source === DASHBOARD_CUSTOM_PROXY_SOURCE) {
const normalizedType = String(assignedProxy.type || "http").toLowerCase();
const hasTypeOption = runtimeProxyTypes.some((entry) => entry.value === normalizedType);
setMode("custom");
setProxyType(hasTypeOption ? normalizedType : runtimeProxyTypes[0]?.value || "http");
setHost(assignedProxy.host || "");
setPort(String(assignedProxy.port || ""));
setUsername(
isRedactedSecret(assignedProxy.username) ? "" : assignedProxy.username || ""
);
setPassword(
isRedactedSecret(assignedProxy.password) ? "" : assignedProxy.password || ""
);
setShowAuth(!!(assignedProxy.username || assignedProxy.password));
if (normalizedType === "socks5" && !runtimeSocks5) {
setFormError(t("errorSocks5Hidden"));
}
} else {
setMode("saved");
}
} else {
setMode("custom");
setSelectedProxyId("");
}
}
// Load own proxy
const params = new URLSearchParams({ level });
if (levelId) params.set("id", levelId);
const res = await fetch(`/api/settings/proxy?${params}`);
if (res.ok) {
const data = await res.json();
const proxy = data.proxy;
if (proxy && proxy.host) {
const normalizedType = String(proxy.type || "http").toLowerCase();
const hasTypeOption = runtimeProxyTypes.some((entry) => entry.value === normalizedType);
setProxyType(hasTypeOption ? normalizedType : runtimeProxyTypes[0]?.value || "http");
setHost(proxy.host || "");
setPort(proxy.port || "");
setUsername(proxy.username || "");
setPassword(proxy.password || "");
setShowAuth(!!(proxy.username || proxy.password));
setHasOwnProxy(true);
if (normalizedType === "socks5" && !runtimeSocks5) {
setFormError(t("errorSocks5Hidden"));
}
if (!hasSavedAssignment) setMode("custom");
} else {
if (!hasSavedAssignment) {
resetFields();
setHasOwnProxy(false);
}
}
}
// Check inherited proxy (for non-global levels)
if (level !== "global" && levelId) {
// Try to resolve the effective proxy to show inheritance info
const fullConfig = await fetch("/api/settings/proxy");
if (fullConfig.ok) {
const config = await fullConfig.json();
// Determine inheritance source
if (level === "key") {
// Check combo, provider, global
if (config.global)
setInheritedFrom({ level: t("levelGlobal"), proxy: config.global });
// Provider info requires more context, showing global as fallback
} else if (level === "combo") {
if (config.global)
setInheritedFrom({ level: t("levelGlobal"), proxy: config.global });
} else if (level === "provider") {
if (config.global)
setInheritedFrom({ level: t("levelGlobal"), proxy: config.global });
}
}
}
} catch (error) {
console.error("Error loading proxy config:", error);
} finally {
setLoading(false);
}
};
loadProxy();
}, [isOpen, level, levelId]);
const resetFields = () => {
setProxyType(proxyTypes[0]?.value || "http");
setHost("");
setPort("");
setUsername("");
setPassword("");
setShowAuth(false);
setFormError(null);
};
const handleSave = async () => {
if (mode === "saved" && !selectedProxyId) {
setFormError(t("errorSelectSavedProxy"));
return;
}
if (mode === "custom" && !String(host || "").trim()) return;
setFormError(null);
setSaving(true);
try {
const scope = getAssignmentScope(level);
const scopeId = getAssignmentScopeId(level, levelId);
let res;
let payload = null;
if (mode === "saved") {
res = await fetch("/api/settings/proxies/assignments", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
scope,
scopeId,
proxyId: selectedProxyId,
}),
});
if (res.ok) {
const clearParams = new URLSearchParams({ level });
if (levelId) clearParams.set("id", levelId);
await fetch(`/api/settings/proxy?${clearParams.toString()}`, { method: "DELETE" });
}
} else {
const trimmedHost = String(host || "").trim();
const normalizedPort = Number(String(port || "").trim() || getDefaultPort(proxyType));
const normalizedUsername = String(username || "").trim();
const normalizedPassword = String(password || "").trim();
const proxy = {
name: getCustomProxyName(level, levelId, levelLabel),
type: proxyType,
host: trimmedHost,
port: normalizedPort,
status: "active",
source: DASHBOARD_CUSTOM_PROXY_SOURCE,
notes: DASHBOARD_CUSTOM_PROXY_NOTES,
};
const createPayload: Record<string, unknown> = { ...proxy };
const assignmentPayload = { scope, scopeId };
if (username !== "***" && normalizedUsername) {
createPayload.username = normalizedUsername;
}
if (password !== "***" && normalizedPassword) {
createPayload.password = normalizedPassword;
}
const existingAssignment = await fetchAssignmentForScope(scope, scopeId);
let safeExistingProxyId: string | null = null;
if (existingAssignment?.proxyId) {
const existingProxy = await fetchRegistryProxy(existingAssignment.proxyId, savedProxies);
if (existingProxy?.source === DASHBOARD_CUSTOM_PROXY_SOURCE) {
const usage = await fetchProxyUsage(existingAssignment.proxyId);
if (
usage.length === 1 &&
usage.some((assignment) => isSameScopeAssignment(assignment, scope, scopeId))
) {
safeExistingProxyId = existingAssignment.proxyId;
}
}
}
if (safeExistingProxyId) {
const updatePayload: Record<string, unknown> = {
id: safeExistingProxyId,
...proxy,
assignment: assignmentPayload,
};
if (username !== "***") {
updatePayload.username = normalizedUsername;
}
if (password !== "***") {
updatePayload.password = normalizedPassword;
}
res = await fetch("/api/settings/proxies", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(updatePayload),
});
} else {
res = await fetch("/api/settings/proxies", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...createPayload, assignment: assignmentPayload }),
});
}
payload = await readJson(res);
const registryPayload = payload;
if (!res.ok) {
setFormError(registryPayload?.error?.message || t("errorSaveProxy"));
return;
}
const registryProxyId = registryPayload?.id || safeExistingProxyId;
if (!registryProxyId) {
setFormError(t("errorSaveProxy"));
return;
}
}
if (!payload) {
payload = await readJson(res);
}
if (!res.ok) {
setFormError(payload?.error?.message || t("errorSaveProxy"));
return;
}
setHasOwnProxy(true);
if (mode === "custom") {
setSelectedProxyId(payload?.assignment?.proxyId || payload?.id || selectedProxyId || "");
}
onSaved?.();
onClose();
} catch (error) {
console.error("Error saving proxy:", error);
setFormError(error.message || t("errorSaveProxy"));
} finally {
setSaving(false);
}
};
const handleClear = async () => {
setFormError(null);
setSaving(true);
try {
const scope = getAssignmentScope(level);
const scopeId = getAssignmentScopeId(level, levelId);
await fetch("/api/settings/proxies/assignments", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
scope,
scopeId,
proxyId: null,
}),
});
const params = new URLSearchParams({ level });
if (levelId) params.set("id", levelId);
const res = await fetch(`/api/settings/proxy?${params}`, { method: "DELETE" });
const payload = await res.json().catch(() => ({}));
if (!res.ok) {
setFormError(payload?.error?.message || t("errorClearProxy"));
return;
}
resetFields();
setHasOwnProxy(false);
setSelectedProxyId("");
setTestResult(null);
onSaved?.();
onClose();
} catch (error) {
console.error("Error clearing proxy:", error);
setFormError(error.message || t("errorClearProxy"));
} finally {
setSaving(false);
}
};
const handleTest = async () => {
setFormError(null);
setTesting(true);
setTestResult(null);
try {
let proxy: {
type: string;
host: string;
port: string;
username?: string;
password?: string;
} | null = null;
if (mode === "saved") {
if (!selectedProxyId) {
setFormError(t("errorSelectProxyFirst"));
setTesting(false);
return;
}
const found = savedProxies.find((p) => p.id === selectedProxyId);
if (!found) {
setFormError(t("errorProxyNotFound"));
setTesting(false);
return;
}
proxy = {
type: found.type || "http",
host: found.host || "",
port: String(found.port || 8080),
};
} else {
if (!String(host || "").trim()) {
setTesting(false);
return;
}
proxy = {
type: proxyType,
host: String(host || "").trim(),
port: String(port || "").trim() || getDefaultPort(proxyType),
username: String(username || "").trim(),
password: String(password || "").trim(),
};
}
const res = await fetch("/api/settings/proxy/test", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ proxy }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const message = data?.error?.message || t("connectionFailed");
setTestResult({ success: false, error: message });
setFormError(message);
return;
}
setTestResult(data);
} catch (error) {
setTestResult({ success: false, error: error.message });
setFormError(error.message || t("connectionFailed"));
} finally {
setTesting(false);
}
};
const title =
level === "global"
? t("titleGlobal")
: t("titleLevel", {
level: t(`level${level.charAt(0).toUpperCase() + level.slice(1)}` as any),
label: levelLabel || levelId || "",
});
return (
<Modal isOpen={isOpen} onClose={onClose} title={title} maxWidth="lg">
{loading ? (
<div className="py-8 text-center text-text-muted animate-pulse">{t("loading")}</div>
) : (
<div className="flex flex-col gap-5">
{/* Inheritance indicator */}
{level !== "global" && !hasOwnProxy && inheritedFrom && (
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-blue-500/10 border border-blue-500/20 text-sm">
<span className="material-symbols-outlined text-blue-400 text-base">
subdirectory_arrow_right
</span>
<span className="text-blue-300">
{t("inheritingFrom")} <strong>{inheritedFrom.level}</strong>:{" "}
{inheritedFrom.proxy?.type}
://{inheritedFrom.proxy?.host}:{inheritedFrom.proxy?.port}
</span>
</div>
)}
{/* Proxy Type Selector */}
<div>
<label className="text-xs text-text-muted mb-1.5 block uppercase tracking-wider font-medium">
{t("source")}
</label>
<div className="flex gap-2">
<button
onClick={() => setMode("saved")}
className={`px-3 py-2 rounded text-sm border transition-colors ${
mode === "saved"
? "bg-primary text-white border-primary"
: "bg-bg-subtle text-text-muted border-border"
}`}
>
{t("savedProxy")}
</button>
<button
onClick={() => setMode("custom")}
className={`px-3 py-2 rounded text-sm border transition-colors ${
mode === "custom"
? "bg-primary text-white border-primary"
: "bg-bg-subtle text-text-muted border-border"
}`}
>
{t("custom")}
</button>
</div>
</div>
{mode === "saved" && (
<div>
<label className="text-xs text-text-muted mb-1.5 block uppercase tracking-wider font-medium">
{t("savedProxy")}
</label>
<select
value={selectedProxyId}
onChange={(e) => setSelectedProxyId(e.target.value)}
className="w-full px-3 py-2.5 rounded-lg bg-bg-subtle border border-border text-sm text-text-primary"
>
<option value="">{t("selectSavedProxyPlaceholder")}</option>
{savedProxies.map((item: any) => (
<option key={item.id} value={item.id}>
{item.name} ({item.type}://{item.host}:{item.port})
</option>
))}
</select>
</div>
)}
{mode === "custom" && (
<>
<div>
<label className="text-xs text-text-muted mb-1.5 block uppercase tracking-wider font-medium">
{t("proxyType")}
</label>
<div className="flex gap-1 bg-bg-subtle rounded-lg p-1 border border-border">
{proxyTypes.map((t) => (
<button
key={t.value}
onClick={() => setProxyType(t.value)}
className={`flex-1 px-4 py-2 rounded-md text-sm font-medium transition-all ${
proxyType === t.value
? "bg-primary text-white shadow-sm"
: "text-text-muted hover:text-text-primary hover:bg-black/5 dark:hover:bg-white/5"
}`}
>
{t.label}
</button>
))}
</div>
</div>
{/* Host + Port */}
<div className="grid grid-cols-3 gap-3">
<div className="col-span-2">
<label className="text-xs text-text-muted mb-1.5 block uppercase tracking-wider font-medium">
{t("host")}
</label>
<input
type="text"
value={host}
onChange={(e) => setHost(e.target.value)}
placeholder={t("hostPlaceholder")}
className="w-full px-3 py-2.5 rounded-lg bg-bg-subtle border border-border text-sm text-text-primary placeholder:text-text-muted/50 focus:outline-none focus:border-primary transition-colors"
/>
</div>
<div>
<label className="text-xs text-text-muted mb-1.5 block uppercase tracking-wider font-medium">
{t("port")}
</label>
<input
type="text"
value={port}
onChange={(e) => setPort(e.target.value)}
placeholder={getDefaultPort(proxyType)}
className="w-full px-3 py-2.5 rounded-lg bg-bg-subtle border border-border text-sm text-text-primary placeholder:text-text-muted/50 focus:outline-none focus:border-primary transition-colors"
/>
</div>
</div>
{/* Auth Toggle */}
<div>
<button
onClick={() => setShowAuth(!showAuth)}
className="flex items-center gap-2 text-sm text-text-muted hover:text-text-primary transition-colors"
>
<span className="material-symbols-outlined text-base">
{showAuth ? "expand_less" : "expand_more"}
</span>
{t("authOptional")}
</button>
{showAuth && (
<div className="grid grid-cols-2 gap-3 mt-3">
<div>
<label className="text-xs text-text-muted mb-1.5 block uppercase tracking-wider font-medium">
{t("username")}
</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder={t("usernamePlaceholder")}
className="w-full px-3 py-2.5 rounded-lg bg-bg-subtle border border-border text-sm text-text-primary placeholder:text-text-muted/50 focus:outline-none focus:border-primary transition-colors"
/>
</div>
<div>
<label className="text-xs text-text-muted mb-1.5 block uppercase tracking-wider font-medium">
{t("password")}
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t("passwordPlaceholder")}
className="w-full px-3 py-2.5 rounded-lg bg-bg-subtle border border-border text-sm text-text-primary placeholder:text-text-muted/50 focus:outline-none focus:border-primary transition-colors"
/>
</div>
</div>
)}
</div>
</>
)}
{/* Test Result */}
{formError && (
<div className="px-4 py-3 rounded-lg border border-red-500/30 bg-red-500/10 text-sm text-red-400">
{formError}
</div>
)}
{testResult && (
<div
className={`flex items-center gap-3 px-4 py-3 rounded-lg border ${
testResult.success
? "bg-emerald-500/10 border-emerald-500/30"
: "bg-red-500/10 border-red-500/30"
}`}
>
<span
className={`material-symbols-outlined text-xl ${
testResult.success ? "text-emerald-400" : "text-red-400"
}`}
>
{testResult.success ? "check_circle" : "error"}
</span>
<div className="flex-1">
{testResult.success ? (
<div>
<span className="text-sm font-medium text-emerald-400">{t("connected")}</span>
<span className="text-text-muted text-xs ml-2">
{t("ip")}{" "}
<span className="font-mono text-emerald-300">{testResult.publicIp}</span>
{testResult.latencyMs && ` · ${testResult.latencyMs}ms`}
</span>
</div>
) : (
<div className="text-sm text-red-400">
{testResult.error || t("connectionFailed")}
{testResult.latencyMs && (
<span className="text-text-muted text-xs ml-2">
({testResult.latencyMs}ms)
</span>
)}
</div>
)}
</div>
</div>
)}
{/* Actions */}
<div className="flex items-center justify-between pt-2 border-t border-border">
<div className="flex gap-2">
<Button
size="sm"
variant="secondary"
icon="speed"
onClick={handleTest}
loading={testing}
disabled={mode === "saved" ? !selectedProxyId : !String(host || "").trim()}
>
{t("testConnection")}
</Button>
{hasOwnProxy && (
<Button
size="sm"
variant="ghost"
icon="delete"
onClick={handleClear}
disabled={saving}
className="!text-red-400 hover:!bg-red-500/10"
>
{t("clear")}
</Button>
)}
</div>
<div className="flex gap-2">
<Button size="sm" variant="secondary" onClick={onClose}>
{t("cancel")}
</Button>
<Button
size="sm"
icon="save"
onClick={handleSave}
loading={saving}
disabled={mode === "saved" ? !selectedProxyId : !String(host || "").trim()}
>
{t("save")}
</Button>
</div>
</div>
</div>
)}
</Modal>
);
}