diff --git a/src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx b/src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx index d2431e83a6..b402927006 100644 --- a/src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx @@ -155,18 +155,25 @@ export default function ProxyRegistryManager() { setSaving(true); setError(null); - const payload = { + const normalizedUsername = form.username.trim(); + const normalizedPassword = form.password.trim(); + + const payload: Record = { ...(editingId ? { id: editingId } : {}), name: form.name.trim(), type: form.type, host: form.host.trim(), port: Number(form.port || 8080), - username: form.username.trim(), - password: form.password.trim(), region: form.region.trim() || null, notes: form.notes.trim() || null, status: form.status, }; + if (!editingId || normalizedUsername.length > 0) { + payload.username = normalizedUsername; + } + if (!editingId || normalizedPassword.length > 0) { + payload.password = normalizedPassword; + } try { const res = await fetch("/api/settings/proxies", { @@ -474,6 +481,7 @@ export default function ProxyRegistryManager() { setForm((prev) => ({ ...prev, username: e.target.value }))} /> @@ -483,6 +491,7 @@ export default function ProxyRegistryManager() { type="password" className="w-full px-3 py-2 rounded bg-bg-subtle border border-border" value={form.password} + placeholder={editingId ? "Leave blank to keep current password" : ""} onChange={(e) => setForm((prev) => ({ ...prev, password: e.target.value }))} /> diff --git a/src/app/api/settings/proxies/migrate/route.ts b/src/app/api/settings/proxies/migrate/route.ts index 206aedcd73..a8def9dc42 100644 --- a/src/app/api/settings/proxies/migrate/route.ts +++ b/src/app/api/settings/proxies/migrate/route.ts @@ -1,12 +1,17 @@ import { migrateLegacyProxyConfigToRegistry } from "@/lib/localDb"; import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse"; +import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; +import { z } from "zod"; + +const migrateLegacyProxySchema = z.object({ + force: z.boolean().optional(), +}); export async function POST(request: Request) { - let force = false; + let rawBody: unknown; try { - const body = await request.json().catch(() => ({})); - force = body?.force === true; + rawBody = await request.json(); } catch { return createErrorResponse({ status: 400, @@ -16,6 +21,17 @@ export async function POST(request: Request) { } try { + const validation = validateBody(migrateLegacyProxySchema, rawBody); + if (isValidationFailure(validation)) { + return createErrorResponse({ + status: 400, + message: validation.error.message, + details: validation.error.details, + type: "invalid_request", + }); + } + + const force = validation.data.force === true; const result = await migrateLegacyProxyConfigToRegistry({ force }); return Response.json(result); } catch (error) { diff --git a/src/app/api/v1/management/proxies/assignments/route.ts b/src/app/api/v1/management/proxies/assignments/route.ts index a16150c997..1653952827 100644 --- a/src/app/api/v1/management/proxies/assignments/route.ts +++ b/src/app/api/v1/management/proxies/assignments/route.ts @@ -2,6 +2,7 @@ import { assignProxyToScope, getProxyAssignments, resolveProxyForConnection } fr import { proxyAssignmentSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; import { clearDispatcherCache } from "@omniroute/open-sse/utils/proxyDispatcher"; function toPagination(searchParams: URLSearchParams) { @@ -11,6 +12,9 @@ function toPagination(searchParams: URLSearchParams) { } export async function GET(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + try { const { searchParams } = new URL(request.url); const proxyId = searchParams.get("proxy_id"); @@ -42,6 +46,9 @@ export async function GET(request: Request) { } export async function PUT(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + let rawBody: unknown; try { rawBody = await request.json(); diff --git a/src/app/api/v1/management/proxies/bulk-assign/route.ts b/src/app/api/v1/management/proxies/bulk-assign/route.ts index 0d69458c56..32a3434d23 100644 --- a/src/app/api/v1/management/proxies/bulk-assign/route.ts +++ b/src/app/api/v1/management/proxies/bulk-assign/route.ts @@ -2,9 +2,13 @@ import { bulkAssignProxyToScope } from "@/lib/localDb"; import { bulkProxyAssignmentSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; import { clearDispatcherCache } from "@omniroute/open-sse/utils/proxyDispatcher"; export async function PUT(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + let rawBody: unknown; try { rawBody = await request.json(); diff --git a/src/app/api/v1/management/proxies/health/route.ts b/src/app/api/v1/management/proxies/health/route.ts index 3ef0ff02f7..e12c10a8b3 100644 --- a/src/app/api/v1/management/proxies/health/route.ts +++ b/src/app/api/v1/management/proxies/health/route.ts @@ -1,7 +1,11 @@ import { getProxyHealthStats } from "@/lib/localDb"; import { createErrorResponseFromUnknown } from "@/lib/api/errorResponse"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; export async function GET(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + try { const { searchParams } = new URL(request.url); const hours = Number(searchParams.get("hours") || 24); diff --git a/src/app/api/v1/management/proxies/route.ts b/src/app/api/v1/management/proxies/route.ts index c5f897d271..eb4be9bbdc 100644 --- a/src/app/api/v1/management/proxies/route.ts +++ b/src/app/api/v1/management/proxies/route.ts @@ -9,6 +9,7 @@ import { import { createProxyRegistrySchema, updateProxyRegistrySchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; function toPagination(searchParams: URLSearchParams) { const limit = Math.max(1, Math.min(200, Number(searchParams.get("limit") || 50))); @@ -17,6 +18,9 @@ function toPagination(searchParams: URLSearchParams) { } export async function GET(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + try { const { searchParams } = new URL(request.url); const id = searchParams.get("id"); @@ -48,6 +52,9 @@ export async function GET(request: Request) { } export async function POST(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + let rawBody: unknown; try { rawBody = await request.json(); @@ -78,6 +85,9 @@ export async function POST(request: Request) { } export async function PATCH(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + let rawBody: unknown; try { rawBody = await request.json(); @@ -113,6 +123,9 @@ export async function PATCH(request: Request) { } export async function DELETE(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + try { const { searchParams } = new URL(request.url); const id = searchParams.get("id"); diff --git a/src/lib/api/requireManagementAuth.ts b/src/lib/api/requireManagementAuth.ts new file mode 100644 index 0000000000..de8c2b4f17 --- /dev/null +++ b/src/lib/api/requireManagementAuth.ts @@ -0,0 +1,18 @@ +import { isAuthenticated, isAuthRequired } from "@/shared/utils/apiAuth"; +import { createErrorResponse } from "@/lib/api/errorResponse"; + +export async function requireManagementAuth(request: Request): Promise { + if (!(await isAuthRequired())) { + return null; + } + + if (await isAuthenticated(request)) { + return null; + } + + return createErrorResponse({ + status: 401, + message: "Authentication required", + type: "invalid_request", + }); +} diff --git a/src/lib/db/proxies.ts b/src/lib/db/proxies.ts index 8ce256508c..a1750c3a52 100644 --- a/src/lib/db/proxies.ts +++ b/src/lib/db/proxies.ts @@ -197,9 +197,23 @@ export async function updateProxy(id: string, payload: Partial) { const existing = await getProxyById(id, { includeSecrets: true }); if (!existing) return null; + const incomingUsername = + typeof payload.username === "string" ? payload.username.trim() : undefined; + const incomingPassword = + typeof payload.password === "string" ? payload.password.trim() : undefined; + const merged = { ...existing, ...payload, + // Preserve stored credentials unless caller explicitly sends non-empty replacements. + username: + incomingUsername === undefined || incomingUsername.length === 0 + ? existing.username + : incomingUsername, + password: + incomingPassword === undefined || incomingPassword.length === 0 + ? existing.password + : incomingPassword, updatedAt: new Date().toISOString(), }; @@ -508,6 +522,7 @@ export async function getProxyHealthStats(options?: { hours?: number }) { LEFT JOIN proxy_logs l ON l.proxy_host = p.host AND l.proxy_type = p.type + AND l.proxy_port = p.port AND l.timestamp >= ? GROUP BY p.id, p.name, p.type, p.host, p.port ORDER BY p.name ASC` diff --git a/src/shared/components/ProxyConfigModal.tsx b/src/shared/components/ProxyConfigModal.tsx index f2dba750ad..81f2204fd7 100644 --- a/src/shared/components/ProxyConfigModal.tsx +++ b/src/shared/components/ProxyConfigModal.tsx @@ -207,6 +207,21 @@ export default function ProxyConfigModal({ await fetch(`/api/settings/proxy?${clearParams.toString()}`, { method: "DELETE" }); } } else { + const clearAssignmentRes = 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 clearAssignmentPayload = await clearAssignmentRes.json().catch(() => ({})); + if (!clearAssignmentRes.ok) { + setFormError(clearAssignmentPayload?.error?.message || "Failed to clear saved proxy"); + return; + } + const proxy = { type: proxyType, host: host.trim(), @@ -226,6 +241,9 @@ export default function ProxyConfigModal({ return; } setHasOwnProxy(true); + if (mode === "custom") { + setSelectedProxyId(""); + } onSaved?.(); } catch (error) { console.error("Error saving proxy:", error); diff --git a/tests/e2e/proxy-registry.smoke.spec.ts b/tests/e2e/proxy-registry.smoke.spec.ts index 1a838c597e..9e2cec3694 100644 --- a/tests/e2e/proxy-registry.smoke.spec.ts +++ b/tests/e2e/proxy-registry.smoke.spec.ts @@ -211,6 +211,9 @@ test.describe("Proxy Registry smoke flow", () => { .toBe(1); await row.getByRole("button", { name: "Delete" }).click(); - await expect(page.locator("table")).not.toContainText("Registry Smoke Proxy"); + await expect + .poll(() => state.proxies.some((proxy) => proxy.name === "Registry Smoke Proxy")) + .toBe(false); + await expect(page.locator("tr", { hasText: "Registry Smoke Proxy" })).toHaveCount(0); }); });