Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/fixes/16628-add-api-key-modal-reset.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **fix(dashboard):** reset the Add API key modal between opens so a saved or cancelled credential no longer reappears in the next add ([#16628](https://github.com/diegosouzapw/OmniRoute/pull/16628)) — thanks @hamedniroomand, @semihisikman
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
// @vitest-environment jsdom
/**
* Provider page: ProviderModalsPanel must mount the Add API key modal only
* while it is open. Kept mounted, the modal's form state (API key, token
* secret, bulk text, …) survived a close or a save and reappeared on the next
* open. Each open has to get a fresh instance.
*/
import React, { act, useEffect } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import ProviderModalsPanel from "../components/ProviderModalsPanel";

const modalLifecycle = vi.hoisted(() => ({ mounts: 0, unmounts: 0 }));

vi.mock("next/navigation", () => ({
useParams: () => ({ id: "openai" }),
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/dashboard/providers/openai",
}));

vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
}));

vi.mock("../components/modals/AddApiKeyModal", () => ({
default: function AddApiKeyModalStub({ isOpen }: { isOpen: boolean }) {
useEffect(() => {
modalLifecycle.mounts += 1;
return () => {
modalLifecycle.unmounts += 1;
};
}, []);
return <div data-testid="add-api-key-modal" data-open={String(isOpen)} />;
},
}));

vi.stubGlobal(
"fetch",
vi.fn(async () => ({
ok: true,
status: 200,
json: async () => ({}),
text: async () => "{}",
headers: { get: () => "application/json" },
}))
);

function basePanelProps(showAddApiKeyModal: boolean) {
return {
providerId: "openai",
providerInfo: { name: "OpenAI" },
isCompatible: false,
isAnthropicProtocolCompatible: false,
isCcCompatible: false,
isCommandCode: false,
isUpstreamProxyProvider: false,
subscriptionRisk: false,
showRiskNoticeModal: false,
showKimiAuthMethodModal: false,
showOAuthModal: false,
reauthConnection: null,
showSiliconFlowEndpointModal: false,
showAddApiKeyModal,
setShowAddApiKeyModal: vi.fn(),
handleCloseAddApiKeyModal: vi.fn(),
handleSaveApiKey: vi.fn(),
existingConnectionNames: [],
siliconFlowInitialBaseUrl: undefined,
commandCodeAuthState: { phase: "idle" },
batchDeleteConfirmOpen: false,
selectedIds: new Set<string>(),
batchDeleting: false,
deleteConfirm: { connection: null, deleting: false, cancel: vi.fn(), confirm: vi.fn() },
applyCodexModalConnectionId: null,
applyingCodexAuthId: null,
importCodexModalOpen: false,
fetchConnections: vi.fn(),
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: null,
setProxyTarget: vi.fn(),
fetchProxyConfig: vi.fn(),
refreshProxyState: vi.fn(),
importProgress: {
current: 0,
total: 0,
phase: "idle",
status: "",
logs: [],
error: "",
importedCount: 0,
},
showImportModal: false,
showTutorialModal: false,
t: (key: string) => key,
};
}

describe("ProviderModalsPanel — Add API key modal mounts only while open", () => {
let container: HTMLElement;
let root: ReturnType<typeof createRoot>;

beforeEach(() => {
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
modalLifecycle.mounts = 0;
modalLifecycle.unmounts = 0;
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});

afterEach(() => {
act(() => {
root.unmount();
});
container.remove();
});

it("gives every open a fresh modal instance", () => {
type PanelProps = React.ComponentProps<typeof ProviderModalsPanel>;
const renderPanel = (open: boolean) =>
act(() => {
root.render(<ProviderModalsPanel {...(basePanelProps(open) as unknown as PanelProps)} />);
});
const modal = () => container.querySelector('[data-testid="add-api-key-modal"]');

renderPanel(false);
expect(modal()).toBeNull();
expect(modalLifecycle.mounts).toBe(0);

renderPanel(true);
expect(modal()?.getAttribute("data-open")).toBe("true");
expect(modalLifecycle.mounts).toBe(1);

renderPanel(false);
expect(modal()).toBeNull();
expect(modalLifecycle.unmounts).toBe(1);

renderPanel(true);
expect(modalLifecycle.mounts).toBe(2);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,9 @@ export default function ProviderModalsPanel({
}}
/>
)}
{!isUpstreamProxyProvider && (
{/* Mount only while open: closing drops the whole form, so a reopened
modal never shows the previous credentials. */}
{!isUpstreamProxyProvider && showAddApiKeyModal && (
<AddApiKeyModal
isOpen={showAddApiKeyModal}
provider={providerId}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ import {
normalizeAndValidateHttpBaseUrl,
extractCommandCodeCredentialInput,
combineModalCredential,
defaultValidationModelIdForProvider,
providerText,
validationBadgeProps,
type CommandCodeAuthFlowState,
Expand All @@ -36,10 +35,10 @@ import HarImportButton from "../HarImportButton";
import CcCompatibleRequestDefaultsFields from "./CcCompatibleRequestDefaultsFields";
import { buildAddProviderSpecificData } from "./connectionProviderSpecificData";
import { getCommandCodeAuthPhaseLabel } from "./commandCodeAuthPhase";
import { computeConnectionDefaultName } from "./computeConnectionDefaultName";
import { createInitialAddApiKeyFormData } from "./addApiKeyInitialFormData";
import AgentrouterConsoleFields from "./AgentrouterConsoleFields";
import QuotaScrapingFields, { EMPTY_QUOTA_SCRAPING_FIELDS } from "./QuotaScrapingFields";
import GlmTeamQuotaFields, { EMPTY_GLM_TEAM_QUOTA_FIELDS } from "./GlmTeamQuotaFields";
import QuotaScrapingFields from "./QuotaScrapingFields";
import GlmTeamQuotaFields from "./GlmTeamQuotaFields";
import * as ProviderRegion from "./AlibabaProviderRegionField";
export interface AddApiKeyModalProps {
isOpen: boolean;
Expand Down Expand Up @@ -114,39 +113,16 @@ export default function AddApiKeyModal({
const apiKeyOptional =
providerAllowsOptionalApiKey(provider) || Boolean(isNoAuthWebSessionCredential);
const commandCodeAuthPhaseLabel = getCommandCodeAuthPhaseLabel(commandCodeAuthState);
const [formData, setFormData] = useState({
name: computeConnectionDefaultName(existingConnectionNames),
apiKey: "",
tokenSecret: "", // #5446 — Modal Token Secret (joined with apiKey as id:secret)
defaultModel: "",
priority: 1,
baseUrl: initialBaseUrl || defaultBaseUrl,
cx: "",
region: showsRegion ? defaultRegion : "",
awsAccessKeyId: "",
awsSessionToken: "",
apiRegion: "international",
validationModelId: defaultValidationModelIdForProvider(provider), // #5446 item 4 — Modal probe model pre-fill
routingTags: "",
excludedModels: "",
customUserAgent: "",
huggingfaceBillTo: "",
accountId: "",
consoleApiKey: "",
newApiUserId: "",
newApiAggregatorBalance: false,
quotaPerUnit: "",
...EMPTY_GLM_TEAM_QUOTA_FIELDS,
...EMPTY_QUOTA_SCRAPING_FIELDS,
ccCompatibleContext1m: false,
ccCompatibleRedactThinking: false,
ccCompatibleSummarizeThinking: false,
passthroughModels: false,
importFreeModelsOnly: false,
tunnelId: "",
runtimeKey: "",
connectorName: CHATGPT_WEB_CODEX_CONNECTOR_NAME,
});
const [formData, setFormData] = useState(() =>
createInitialAddApiKeyFormData({
provider,
existingConnectionNames,
initialBaseUrl,
defaultBaseUrl,
showsRegion,
defaultRegion,
})
);
const [validating, setValidating] = useState(false);
const [validationResult, setValidationResult] = useState(null);
const [validationCapabilities, setValidationCapabilities] = useState<Record<
Expand All @@ -157,22 +133,47 @@ export default function AddApiKeyModal({
const [saveError, setSaveError] = useState<string | null>(null);
const [showAdvanced, setShowAdvanced] = useState(false);
const [copiedCommandCodeField, setCopiedCommandCodeField] = useState<string | null>(null);
const resetOpenRouterPreset = openRouterPreset.setValue;
const wasOpenRef = useRef(false);
useEffect(() => {
const wasOpen = wasOpenRef.current;
wasOpenRef.current = isOpen;
if (!isOpen || wasOpen) return;
// On open, reset baseUrl and assign a unique default name so a second API key
// for the same provider doesn't reuse "main" and trigger the backend
// name-based upsert that would silently overwrite the first connection (#6499, #11033).
setFormData((current) => ({
...current,
name: computeConnectionDefaultName(existingConnectionNames),
baseUrl: initialBaseUrl || defaultBaseUrl,
}));
// On open, start from a fresh form: the modal stays mounted between opens, so
// without this the previous key, bulk text and tab survived a close/save. The
// fresh form also carries a unique default name so a second API key for the
// same provider doesn't reuse "main" and trigger the backend name-based upsert
// that would silently overwrite the first connection (#6499, #11033).
setFormData(
createInitialAddApiKeyFormData({
provider,
existingConnectionNames,
initialBaseUrl,
defaultBaseUrl,
showsRegion,
defaultRegion,
})
);
setValidationResult(null);
setValidationCapabilities(null);
setSaveError(null);
}, [defaultBaseUrl, initialBaseUrl, isOpen, existingConnectionNames]);
setShowAdvanced(false);
setMode("single");
setBulkText("");
setBulkValidateKeys(false);
setBulkResult(null);
setBulkWarnings([]);
resetOpenRouterPreset("");
}, [
defaultBaseUrl,
defaultRegion,
existingConnectionNames,
initialBaseUrl,
isOpen,
provider,
resetOpenRouterPreset,
showsRegion,
]);
const bulkSupported = supportsBulkApiKey(provider);
const [mode, setMode] = useState<"single" | "bulk">("single");
const [bulkText, setBulkText] = useState("");
Expand Down
Loading
Loading