feat: implement proxy registry, management APIs, docs, and test hardening

This commit is contained in:
rexname
2026-03-17 13:05:27 +07:00
parent 9b255e643a
commit 8091b6b508
32 changed files with 3279 additions and 266 deletions

View File

@@ -33,7 +33,24 @@ const LEVEL_LABELS = {
* @param {string} [props.levelLabel] — display name for the level
* @param {Function} [props.onSaved] — callback after save
*/
export default function ProxyConfigModal({ isOpen, onClose, level, levelId, levelLabel, onSaved }: { isOpen: any; onClose: any; level: any; levelId?: any; levelLabel?: any; onSaved?: any }) {
export default function ProxyConfigModal({
isOpen,
onClose,
level,
levelId,
levelLabel,
onSaved,
}: {
isOpen: any;
onClose: any;
level: any;
levelId?: any;
levelLabel?: any;
onSaved?: any;
}) {
const [mode, setMode] = useState("saved");
const [savedProxies, setSavedProxies] = useState([]);
const [selectedProxyId, setSelectedProxyId] = useState("");
const [proxyType, setProxyType] = useState(PROXY_TYPES[0]?.value || "http");
const [host, setHost] = useState("");
const [port, setPort] = useState("");
@@ -63,6 +80,36 @@ export default function ProxyConfigModal({ isOpen, onClose, level, levelId, leve
const loadProxy = async () => {
try {
let hasSavedAssignment = false;
const registryRes = await fetch("/api/settings/proxies");
if (registryRes.ok) {
const registryPayload = await registryRes.json();
setSavedProxies(Array.isArray(registryPayload?.items) ? registryPayload.items : []);
} else {
setSavedProxies([]);
}
const scope = level === "key" ? "account" : level;
const assignmentParams = new URLSearchParams({ scope });
if (level !== "global" && levelId) {
assignmentParams.set("scopeId", levelId);
}
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[0];
if (target?.proxyId) {
setMode("saved");
setSelectedProxyId(target.proxyId);
setHasOwnProxy(true);
hasSavedAssignment = true;
} else {
setMode("custom");
setSelectedProxyId("");
}
}
// Load own proxy
const params = new URLSearchParams({ level });
if (levelId) params.set("id", levelId);
@@ -85,9 +132,12 @@ export default function ProxyConfigModal({ isOpen, onClose, level, levelId, leve
"SOCKS5 is configured but hidden because NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=false."
);
}
if (!hasSavedAssignment) setMode("custom");
} else {
resetFields();
setHasOwnProxy(false);
if (!hasSavedAssignment) {
setHasOwnProxy(false);
}
}
}
@@ -130,22 +180,46 @@ export default function ProxyConfigModal({ isOpen, onClose, level, levelId, leve
};
const handleSave = async () => {
if (!host.trim()) return;
if (mode === "saved" && !selectedProxyId) {
setFormError("Select a saved proxy before saving.");
return;
}
if (mode === "custom" && !host.trim()) return;
setFormError(null);
setSaving(true);
try {
const proxy = {
type: proxyType,
host: host.trim(),
port: port.trim() || getDefaultPort(proxyType),
username: username.trim(),
password: password.trim(),
};
const res = await fetch("/api/settings/proxy", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ level, id: levelId, proxy }),
});
const scope = level === "key" ? "account" : level;
let res;
if (mode === "saved") {
res = await fetch("/api/settings/proxies/assignments", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
scope,
scopeId: level === "global" ? null : levelId,
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 proxy = {
type: proxyType,
host: host.trim(),
port: port.trim() || getDefaultPort(proxyType),
username: username.trim(),
password: password.trim(),
};
res = await fetch("/api/settings/proxy", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ level, id: levelId, proxy }),
});
}
const payload = await res.json().catch(() => ({}));
if (!res.ok) {
setFormError(payload?.error?.message || "Failed to save proxy configuration");
@@ -165,6 +239,17 @@ export default function ProxyConfigModal({ isOpen, onClose, level, levelId, leve
setFormError(null);
setSaving(true);
try {
const scope = level === "key" ? "account" : level;
await fetch("/api/settings/proxies/assignments", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
scope,
scopeId: level === "global" ? null : levelId,
proxyId: null,
}),
});
const params = new URLSearchParams({ level });
if (levelId) params.set("id", levelId);
const res = await fetch(`/api/settings/proxy?${params}`, { method: "DELETE" });
@@ -175,6 +260,7 @@ export default function ProxyConfigModal({ isOpen, onClose, level, levelId, leve
}
resetFields();
setHasOwnProxy(false);
setSelectedProxyId("");
setTestResult(null);
onSaved?.();
} catch (error) {
@@ -186,6 +272,10 @@ export default function ProxyConfigModal({ isOpen, onClose, level, levelId, leve
};
const handleTest = async () => {
if (mode === "saved") {
setFormError("Use custom mode to run manual connection test.");
return;
}
if (!host.trim()) return;
setFormError(null);
setTesting(true);
@@ -248,93 +338,145 @@ export default function ProxyConfigModal({ isOpen, onClose, level, levelId, leve
{/* Proxy Type Selector */}
<div>
<label className="text-xs text-text-muted mb-1.5 block uppercase tracking-wider font-medium">
Proxy Type
Source
</label>
<div className="flex gap-1 bg-bg-subtle rounded-lg p-1 border border-border">
{PROXY_TYPES.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 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"
}`}
>
Saved Proxy
</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"
}`}
>
Custom
</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">
Host
</label>
<input
type="text"
value={host}
onChange={(e) => setHost(e.target.value)}
placeholder="1.2.3.4 or proxy.example.com"
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>
{mode === "saved" && (
<div>
<label className="text-xs text-text-muted mb-1.5 block uppercase tracking-wider font-medium">
Port
Saved Proxy
</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"
/>
<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="">Select saved proxy...</option>
{savedProxies.map((item: any) => (
<option key={item.id} value={item.id}>
{item.name} ({item.type}://{item.host}:{item.port})
</option>
))}
</select>
</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>
Authentication (optional)
</button>
{showAuth && (
<div className="grid grid-cols-2 gap-3 mt-3">
<div>
{mode === "custom" && (
<>
<div>
<label className="text-xs text-text-muted mb-1.5 block uppercase tracking-wider font-medium">
Proxy Type
</label>
<div className="flex gap-1 bg-bg-subtle rounded-lg p-1 border border-border">
{PROXY_TYPES.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">
Username
Host
</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Username"
value={host}
onChange={(e) => setHost(e.target.value)}
placeholder="1.2.3.4 or proxy.example.com"
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">
Password
Port
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
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>
)}
</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>
Authentication (optional)
</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">
Username
</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Username"
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">
Password
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
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 && (
@@ -390,7 +532,7 @@ export default function ProxyConfigModal({ isOpen, onClose, level, levelId, leve
icon="speed"
onClick={handleTest}
loading={testing}
disabled={!host.trim()}
disabled={mode !== "custom" || !host.trim()}
>
Test Connection
</Button>
@@ -416,7 +558,7 @@ export default function ProxyConfigModal({ isOpen, onClose, level, levelId, leve
icon="save"
onClick={handleSave}
loading={saving}
disabled={!host.trim()}
disabled={mode === "saved" ? !selectedProxyId : !host.trim()}
>
Save
</Button>

View File

@@ -551,7 +551,7 @@ export const removeModelAliasSchema = z.object({
from: z.string().trim().min(1),
});
const proxyConfigSchema = z
export const proxyConfigSchema = z
.object({
type: z
.preprocess(
@@ -621,6 +621,67 @@ export const testProxySchema = z.object({
}),
});
export const createProxyRegistrySchema = z
.object({
name: z.string().trim().min(1, "name is required").max(120),
type: z
.preprocess(
(value) => (typeof value === "string" ? value.trim().toLowerCase() : value),
z.enum(["http", "https", "socks5"])
)
.optional()
.default("http"),
host: z.string().trim().min(1, "host is required").max(255),
port: z.coerce.number().int().min(1).max(65535),
username: z.string().optional(),
password: z.string().optional(),
region: z.string().trim().max(64).nullable().optional(),
notes: z.string().trim().max(1000).nullable().optional(),
status: z.enum(["active", "inactive"]).optional().default("active"),
})
.strict();
export const updateProxyRegistrySchema = createProxyRegistrySchema.partial().extend({
id: z.string().trim().min(1, "id is required"),
});
export const proxyAssignmentSchema = z
.object({
scope: z.enum(["global", "provider", "account", "combo", "key"]),
scopeId: z.string().trim().nullable().optional(),
proxyId: z.string().trim().nullable().optional(),
})
.strict()
.superRefine((value, ctx) => {
if (value.scope !== "global" && !value.scopeId?.trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "scopeId is required for provider/account/combo/key scope",
path: ["scopeId"],
});
}
});
export const bulkProxyAssignmentSchema = z
.object({
scope: z.enum(["global", "provider", "account", "combo", "key"]),
scopeIds: z.array(z.string().trim().min(1)).optional().default([]),
proxyId: z.string().trim().nullable().optional(),
})
.strict()
.superRefine((value, ctx) => {
if (
value.scope !== "global" &&
(!Array.isArray(value.scopeIds) || value.scopeIds.length === 0)
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "scopeIds is required for provider/account/combo/key scope",
path: ["scopeIds"],
});
}
});
const jsonRecordSchema = z.record(z.string(), z.unknown());
const nonEmptyJsonRecordSchema = jsonRecordSchema.refine(
(value) => Object.keys(value).length > 0,