diff --git a/changelog.d/fixes/13711-provider-proxy-badge-refresh.md b/changelog.d/fixes/13711-provider-proxy-badge-refresh.md new file mode 100644 index 0000000000..f786290bbb --- /dev/null +++ b/changelog.d/fixes/13711-provider-proxy-badge-refresh.md @@ -0,0 +1 @@ +- **fix(dashboard):** Saving or clearing a proxy on a provider page now refreshes the per-connection proxy badges immediately instead of leaving them stale until a manual reload ([#13711](https://github.com/diegosouzapw/OmniRoute/pull/13711)) diff --git a/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx b/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx index 55779730ce..7e06b58195 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx @@ -144,7 +144,7 @@ export default function ProviderDetailPageClient() { setBatchTestResults, setProviderNode, fetchConnections, - fetchProxyConfig, + refreshProxyState, deleteConfirm, handleUpdateConnectionStatus, handleToggleRateLimit, @@ -911,7 +911,7 @@ export default function ProviderDetailPageClient() { emailsVisible={emailsVisible} proxyTarget={proxyTarget} setProxyTarget={setProxyTarget} - fetchProxyConfig={fetchProxyConfig} + refreshProxyState={refreshProxyState} importProgress={importProgress} showImportModal={showImportModal} setShowImportModal={setShowImportModal} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/__tests__/proxySaveRefresh.test.tsx b/src/app/(dashboard)/dashboard/providers/[id]/__tests__/proxySaveRefresh.test.tsx new file mode 100644 index 0000000000..253188cf54 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/__tests__/proxySaveRefresh.test.tsx @@ -0,0 +1,234 @@ +// @vitest-environment jsdom +/** + * Provider page: saving a proxy from the provider/account proxy modal must + * refresh the per-connection proxy badges, not only the provider-level config. + * + * ProxyConfigModal writes the assignment through + * `PUT /api/settings/proxies/assignments` (registry scope assignment), but the + * account-row badges on the provider page are fed by `connProxyMap`, which is + * filled from `GET /api/settings/proxy?resolve=`. The modal's + * `onSaved` callback refreshed only `proxyConfig` + * (`GET /api/settings/proxy`), so a saved proxy stayed invisible on the page + * until a manual reload. + * + * This wires the same production pieces the real page composes — + * useProviderConnections (owns connProxyMap + the refresh callbacks) and + * ProviderModalsPanel (owns the ProxyConfigModal `onSaved` binding) — and + * asserts the refresh contract through that public path, so it holds for any + * implementation of the refresh rather than pinning one helper name. + */ +import React, { act, useEffect, useState } from "react"; +import { createRoot } from "react-dom/client"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock("next/navigation", () => ({ + useParams: () => ({ id: "codex" }), + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), + usePathname: () => "/dashboard/providers/codex", +})); + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})); + +vi.mock("@/store/notificationStore", () => ({ + useNotificationStore: () => ({ + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warning: vi.fn(), + }), +})); + +// Stand in for the real ProxyConfigModal: it only needs to expose the `onSaved` +// callback the page binds, which is what fires after a successful save. +vi.mock("@/shared/components", async () => { + const actual = await vi.importActual>("@/shared/components"); + return { + ...actual, + ProxyConfigModal: ({ onSaved }: { onSaved?: () => void }) => ( + + ), + }; +}); + +const CONNECTION_ID = "conn-proxy-refresh"; + +/** What `GET /api/settings/proxy?resolve=` currently reports. */ +let resolvedProxy: { name: string; host: string } | null = null; + +const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : String(input); + const json = (body: unknown) => ({ + ok: true, + status: 200, + json: async () => body, + text: async () => JSON.stringify(body), + headers: { get: () => "application/json" }, + }); + + if (url.startsWith("/api/settings/proxy?resolve=")) { + return json(resolvedProxy ? { proxy: resolvedProxy, level: "account" } : { proxy: null }); + } + if (url.startsWith("/api/settings/proxy")) { + return json({ global: null, providers: {} }); + } + if (url.startsWith("/api/providers")) { + return json({ + connections: [{ id: CONNECTION_ID, provider: "codex", name: "Account A", priority: 1 }], + }); + } + if (url.startsWith("/api/provider-nodes")) { + return json({ nodes: [] }); + } + return json({}); +}); + +vi.stubGlobal("fetch", fetchMock); + +function resolveCallCount() { + return fetchMock.mock.calls.filter((call) => + String(call[0]).startsWith("/api/settings/proxy?resolve=") + ).length; +} + +describe("provider page — proxy save refreshes per-connection proxy badges", () => { + let container: HTMLElement; + let root: ReturnType; + + beforeEach(() => { + resolvedProxy = null; + fetchMock.mockClear(); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + container.remove(); + }); + + it("re-resolves connection proxies when ProxyConfigModal reports a save", async () => { + const { useProviderConnections } = await import("../hooks/useProviderConnections"); + const ProviderModalsPanel = (await import("../components/ProviderModalsPanel")).default; + + type HookResult = ReturnType; + let hook: HookResult | null = null; + + // Mirrors the real page: the hook owns connProxyMap + refresh callbacks, + // ProviderModalsPanel owns the ProxyConfigModal binding. + function Harness() { + const hookResult = useProviderConnections("codex", false, false); + const [proxyTarget, setProxyTarget] = useState<{ + level: string; + id: string; + label: string; + } | null>({ level: "key", id: CONNECTION_ID, label: "Account A" }); + + useEffect(() => { + hook = hookResult; + }, [hookResult]); + + const panelProps = { + providerId: "codex", + providerInfo: { name: "Codex" }, + isCompatible: false, + isAnthropicProtocolCompatible: false, + isCcCompatible: false, + isCommandCode: false, + isUpstreamProxyProvider: false, + subscriptionRisk: false, + showRiskNoticeModal: false, + showKimiAuthMethodModal: false, + showOAuthModal: false, + reauthConnection: null, + showSiliconFlowEndpointModal: false, + showAddApiKeyModal: false, + siliconFlowInitialBaseUrl: undefined, + commandCodeAuthState: { phase: "idle" }, + batchDeleteConfirmOpen: false, + selectedIds: new Set(), + batchDeleting: false, + deleteConfirm: hookResult.deleteConfirm, + applyCodexModalConnectionId: null, + applyingCodexAuthId: null, + importCodexModalOpen: false, + fetchConnections: hookResult.fetchConnections, + externalLinkModalOpen: false, + externalLinkLoading: false, + externalLinkError: null, + externalLinkUrl: null, + externalLinkCopied: false, + showEditModal: false, + selectedConnection: null, + showEditNodeModal: false, + providerNode: null, + codexCliGuideOpen: false, + applyClaudeModalConnectionId: null, + applyingClaudeAuthId: null, + importClaudeModalOpen: false, + importGrokCliModalOpen: false, + batchTestResults: null, + emailsVisible: false, + proxyTarget, + setProxyTarget, + fetchProxyConfig: hookResult.fetchProxyConfig, + refreshProxyState: hookResult.refreshProxyState, + importProgress: { + current: 0, + total: 0, + phase: "idle", + status: "", + logs: [], + error: "", + importedCount: 0, + }, + showImportModal: false, + showTutorialModal: false, + t: (key: string) => key, + }; + + return ; + } + + await act(async () => { + root.render(); + }); + // Let the connections effect settle so connProxyMap is populated once. + await act(async () => { + await Promise.resolve(); + }); + + expect(hook).not.toBeNull(); + expect(hook!.connProxyMap[CONNECTION_ID]).toBeNull(); + + const resolvesBeforeSave = resolveCallCount(); + expect(resolvesBeforeSave).toBeGreaterThan(0); + + // The user picks a proxy and saves: the assignment now exists server-side. + resolvedProxy = { name: "Saved Proxy", host: "10.0.0.9" }; + + const saveButton = container.querySelector('[data-testid="proxy-saved"]'); + expect(saveButton).not.toBeNull(); + + await act(async () => { + saveButton!.click(); + }); + await act(async () => { + await Promise.resolve(); + }); + + expect(resolveCallCount()).toBeGreaterThan(resolvesBeforeSave); + expect(hook!.connProxyMap[CONNECTION_ID]).toEqual({ + proxy: { name: "Saved Proxy", host: "10.0.0.9" }, + level: "account", + }); + }, 30000); +}); diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderModalsPanel.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderModalsPanel.tsx index a4d994eac5..4ae2c72f86 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderModalsPanel.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderModalsPanel.tsx @@ -137,7 +137,7 @@ interface ProviderModalsPanelProps { // Proxy config proxyTarget: ProxyTarget | null; setProxyTarget: (t: ProxyTarget | null) => void; - fetchProxyConfig: () => Promise; + refreshProxyState: () => Promise; // Import progress importProgress: ImportProgress; showImportModal: boolean; @@ -221,7 +221,7 @@ export default function ProviderModalsPanel({ emailsVisible, proxyTarget, setProxyTarget, - fetchProxyConfig, + refreshProxyState, importProgress, showImportModal, setShowImportModal, @@ -444,7 +444,7 @@ export default function ProviderModalsPanel({ levelId={proxyTarget.id} levelLabel={proxyTarget.label} onSaved={() => { - void fetchProxyConfig(); + void refreshProxyState(); }} /> )} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderConnections.ts b/src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderConnections.ts index 6ae2dfe5c8..bed7707e50 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderConnections.ts +++ b/src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderConnections.ts @@ -21,7 +21,7 @@ * providers constants) — never from ProviderDetailPageClient. */ -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect, useCallback, useRef } from "react"; import { useTranslations } from "next-intl"; import { useNotificationStore } from "@/store/notificationStore"; import { isClaudeCodeCompatibleProvider } from "@/shared/constants/providers"; @@ -191,6 +191,7 @@ export interface UseProviderConnectionsReturn { // Connection fetch fetchConnections: () => Promise; fetchProxyConfig: () => Promise; + refreshProxyState: () => Promise; // Single-connection handlers deleteConfirm: ConnectionDeleteConfirmState; @@ -293,6 +294,13 @@ export function useProviderConnections( Record >({}); + // Latest connections, readable from a stable callback without making that + // callback (and every consumer prop depending on it) change every fetch. + const connectionsRef = useRef(connections); + useEffect(() => { + connectionsRef.current = connections; + }, [connections]); + // ── Upstream proxy routing state (native / CLIProxyAPI / Dario / fallback) ─ const [upstreamProxyMode, setUpstreamProxyModeState] = useState("native"); const [upstreamProxyFallbackBackend, setUpstreamProxyFallbackBackendState] = @@ -314,6 +322,28 @@ export function useProviderConnections( if (result) setProxyConfig(result.config); }, []); + /** + * Refresh every proxy view the page renders after a proxy assignment is + * written elsewhere (ProxyConfigModal saves/clears through + * `/api/settings/proxies/assignments`). + * + * Two independent sources back those views and BOTH must be re-read: + * - `proxyConfig` ← GET /api/settings/proxy (provider-level chip) + * - `connProxyMap` ← GET /api/settings/proxy?resolve= (per-connection badges) + * + * The `connProxyMap` effect below is keyed on [loading, connections], and a + * proxy save changes neither, so without this callback the account-row + * badges keep showing pre-save state until a manual reload. + */ + const refreshProxyState = useCallback(async () => { + const [configResult, map] = await Promise.all([ + loadProxyConfigData(), + resolveConnectionProxies(connectionsRef.current), + ]); + if (configResult) setProxyConfig(configResult.config); + if (map) setConnProxyMap(map); + }, []); + const fetchConnections = useCallback(async () => { const result = await loadProviderConnectionsData(providerId, isCompatible); if (result) { @@ -1108,6 +1138,7 @@ export function useProviderConnections( // Fetch fetchConnections, fetchProxyConfig, + refreshProxyState, // Single-connection handlers deleteConfirm,