mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-02 05:12:11 +03:00
#3501 Phase 1c: extract AddApiKeyModal, EditConnectionModal, WebSessionCredentialGuide into components/; god-component 10,166->8,092 LOC. Reconciles the v3.8.22 file-size drift for this file. Co-authored-by: oyi77 <oyi77@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
acadb593d8
commit
9196a5427e
@@ -14,6 +14,7 @@
|
||||
|
||||
### ♻️ Code Quality
|
||||
|
||||
- **providers/[id]: extract AddApiKeyModal + EditConnectionModal (+ WebSessionCredentialGuide) from the god-component into components/** ([#3501] Phase 1c): extracted the two heaviest inline modals — `AddApiKeyModal` (~787-LOC body) and `EditConnectionModal` (~1091-LOC body) — plus shared `WebSessionCredentialGuide` (~103 LOC) into standalone files under `providers/[id]/components/modals/` and `providers/[id]/components/` respectively. Added `ERROR_TYPE_LABELS` and `formatTimeAgo` to `providerPageHelpers.ts` (leaf) so `EditConnectionModal` and `ConnectionRow` share them without cycles. Pruned 14 now-unused imports from the god-component. `ProviderDetailPageClient.tsx`: 9,981 → 8,092 LOC (−1,889 lines). Frozen baselines: `AddApiKeyModal.tsx: 842`, `EditConnectionModal.tsx: 1170`. 6 new Phase-1c smoke tests; all 21 vitest modal tests pass; typecheck/cycles/lint green.
|
||||
- **refactor: small db/utils cleanup** ([#3523] — thanks @androw): table-driven `compression_analytics` column migration (replaces 17 repeated `ALTER TABLE` calls), a single merged `serializeJsonField` helper in `db/providers.ts` (folded two byte-identical serializers), and removal of the dead no-op `syncProviderDataToCloud`/`getProvidersNeedingRefresh` stubs from `shared/utils/machine.ts` (no remaining callers). Pure refactor; behavior unchanged.
|
||||
- **Provider-detail god-component decomposition — Phase 2b (remaining shared helpers→leaf)** ([#3501]): extended `providers/[id]/providerPageHelpers.ts` with all remaining pure helpers needed by the heavy modals (`AddApiKeyModal`/`EditConnectionModal`) before they can be extracted. Moved 22 symbols: web-session credential label/hint/check/title helpers; upstream-headers helpers (`upstreamHeadersRecordsEqual`, `headerRowsToRecord`, `effectiveUpstreamHeadersForProtocol`, `anyUpstreamHeadersBadge`, `getProtoSlice`) plus their `HeaderDraftRow`/`CompatModelRow`/`CompatModelMap`/`CompatByProtocolMap` types; Codex consts and helpers (`CODEX_REASONING_STRENGTH_OPTIONS`, `CODEX_ACCOUNT_SERVICE_TIER_VALUES`, `CODEX_GLOBAL_SERVICE_MODE_VALUES`, `getCodexServiceTierLabel`, `normalizeCodexLimitPolicy`, `getCodexRequestDefaults`, `getClaudeCodeCompatibleRequestDefaults`); misc helpers (`compatProtocolLabelKey`, `extractCommandCodeCredentialInput`, `normalizeAndValidateHttpBaseUrl`, `SILICONFLOW_ENDPOINTS`, `CommandCodeAuthFlowState`). New transitive imports wired into the leaf: `MODEL_COMPAT_PROTOCOL_KEYS` (`@/shared/constants/modelCompat`), `CodexServiceTier`/`getCodexRequestDefaults`/`getClaudeCodeCompatibleRequestDefaults` (`@/lib/providers/requestDefaults`), `CodexGlobalServiceMode` (`@/lib/providers/codexFastTier`), `WebSessionCredentialRequirement` (`./webSessionCredentials`). `ProviderDetailPageClient.tsx`: 10,288 → 9,980 LOC. Leaf module: 589 LOC (acyclic). 25-assertion unit test suite passes; smoke test 3/3; no import cycles. Co-authored with @oyi77.
|
||||
- **Provider-detail god-component decomposition — Phase 2 (helpers→lib)** ([#3501]): extracted the pure shared helpers — `ProviderMessageTranslator`/`LocalProviderMetadata` types, `providerText`/`providerCountText`/`readBooleanToggle`, and the provider base-URL + routing-tag/excluded-model parse/format block — into a new leaf `providers/[id]/providerPageHelpers.ts` (imports only `@/shared`, so the client and modals share them with no import cycle). `ProviderDetailPageClient.tsx`: 10,435 → 10,288 LOC. Unblocks extracting the heavier `AddApiKeyModal`/`EditConnectionModal` (which depend on these helpers) without cycling. The Phase 0 smoke test caught a missing transitive import (`isSelfHostedChatProvider`) at mount — now wired + locked by a new helpers unit test (12 assertions). Co-authored with @oyi77.
|
||||
|
||||
@@ -48,7 +48,9 @@
|
||||
"src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx": 2570,
|
||||
"src/app/(dashboard)/dashboard/health/page.tsx": 1091,
|
||||
"src/app/(dashboard)/dashboard/playground/components/tabs/ApiTab.tsx": 847,
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx": 9981,
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx": 8093,
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx": 843,
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx": 1171,
|
||||
"src/app/(dashboard)/dashboard/providers/components/onboarding/ProviderOnboardingWizard.tsx": 906,
|
||||
"src/app/(dashboard)/dashboard/providers/page.tsx": 1925,
|
||||
"src/app/(dashboard)/dashboard/runtime/RuntimePageClient.tsx": 1127,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
// Issue #3501 Phase 1c — extracted from the god-component.
|
||||
// Shared by AddApiKeyModal and EditConnectionModal; imports only leaf modules
|
||||
// (no cycle risk).
|
||||
|
||||
import type { WebSessionCredentialRequirement } from "../webSessionCredentials";
|
||||
import { providerText, type ProviderMessageTranslator } from "../providerPageHelpers";
|
||||
|
||||
export interface WebSessionCredentialGuideProps {
|
||||
requirement: WebSessionCredentialRequirement;
|
||||
providerName: string;
|
||||
t: ProviderMessageTranslator;
|
||||
}
|
||||
|
||||
export default function WebSessionCredentialGuide({
|
||||
requirement,
|
||||
providerName,
|
||||
t,
|
||||
}: WebSessionCredentialGuideProps) {
|
||||
if (requirement.kind === "none") {
|
||||
return (
|
||||
<div className="rounded-lg border border-emerald-500/25 bg-emerald-500/10 px-3 py-3 text-sm text-text-muted">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="material-symbols-outlined mt-0.5 text-[18px] text-emerald-500">
|
||||
check_circle
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-medium text-text-main">
|
||||
{providerText(t, "webNoAuthGuideTitle", "No credential required")}
|
||||
</p>
|
||||
<p className="mt-1">
|
||||
{providerText(
|
||||
t,
|
||||
"webNoAuthGuideBody",
|
||||
"{provider} does not need an API key or cookie. Save the connection to use its free web endpoint.",
|
||||
{ provider: providerName }
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const requiredCredentialKey =
|
||||
requirement.kind === "token" ? "webTokenRequiredCredential" : "webCookieRequiredCredential";
|
||||
const requiredCredentialFallback =
|
||||
requirement.kind === "token" ? "Required token: {credential}" : "Required cookie: {credential}";
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-purple-500/25 bg-purple-500/10 px-3 py-3 text-sm text-text-muted">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="material-symbols-outlined mt-0.5 text-[18px] text-purple-500">cookie</span>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<p className="font-medium text-text-main">
|
||||
{providerText(t, "webSessionGuideTitle", "How to get the session credential")}
|
||||
</p>
|
||||
<p className="mt-1">
|
||||
{providerText(
|
||||
t,
|
||||
"webSessionGuideIntro",
|
||||
"{provider} uses a browser web session instead of an API key.",
|
||||
{ provider: providerName }
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<p className="font-medium text-text-main">
|
||||
{providerText(t, requiredCredentialKey, requiredCredentialFallback, {
|
||||
credential: requirement.credentialName,
|
||||
})}
|
||||
</p>
|
||||
<ol className="list-decimal space-y-1 pl-5">
|
||||
<li>
|
||||
{providerText(t, "webSessionGuideStep1", "Sign in to {provider} in your browser.", {
|
||||
provider: providerName,
|
||||
})}
|
||||
</li>
|
||||
<li>
|
||||
{providerText(
|
||||
t,
|
||||
"webSessionGuideStep2",
|
||||
"Open the browser developer tools and inspect a request made by the web app."
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
{providerText(
|
||||
t,
|
||||
"webSessionGuideStep3",
|
||||
"Copy the required credential from the provider's own domain. For cookies, copy only the Cookie header value and omit Cookie:.",
|
||||
{ credential: requirement.credentialName }
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
{providerText(
|
||||
t,
|
||||
"webSessionGuideStep4",
|
||||
"Paste it here and check the connection. If it stops working, sign in again and replace it with a fresh value."
|
||||
)}
|
||||
</li>
|
||||
</ol>
|
||||
<p className="text-xs text-amber-700 dark:text-amber-300">
|
||||
{providerText(
|
||||
t,
|
||||
"webSessionSecurityHint",
|
||||
"Treat this like a password: it may access your signed-in web account until it expires or is revoked."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,842 @@
|
||||
"use client";
|
||||
|
||||
// Issue #3501 Phase 1c — extracted from the god-component.
|
||||
// ~787-LOC modal for adding a new API key / credential to a provider.
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button, Badge, Input, Modal, Toggle } from "@/shared/components";
|
||||
import {
|
||||
providerAllowsOptionalApiKey,
|
||||
supportsBulkApiKey,
|
||||
} from "@/shared/constants/providers";
|
||||
import { parseBulkApiKeys } from "@/shared/utils/bulkApiKeyParser";
|
||||
import {
|
||||
isBaseUrlConfigurableProvider,
|
||||
getProviderBaseUrlDefault,
|
||||
getProviderBaseUrlHint,
|
||||
getProviderBaseUrlPlaceholder,
|
||||
isGlmProvider,
|
||||
parseRoutingTagsInput,
|
||||
parseExcludedModelsInput,
|
||||
getWebSessionCredentialLabel,
|
||||
getWebSessionCredentialHint,
|
||||
getWebSessionCredentialCheckLabel,
|
||||
getAddCredentialModalTitle,
|
||||
getLocalProviderMetadata,
|
||||
normalizeAndValidateHttpBaseUrl,
|
||||
extractCommandCodeCredentialInput,
|
||||
providerText,
|
||||
type CommandCodeAuthFlowState,
|
||||
} from "../../providerPageHelpers";
|
||||
import { getWebSessionCredentialRequirement } from "../../webSessionCredentials";
|
||||
import WebSessionCredentialGuide from "../WebSessionCredentialGuide";
|
||||
|
||||
export interface AddApiKeyModalProps {
|
||||
isOpen: boolean;
|
||||
provider?: string;
|
||||
providerName?: string;
|
||||
initialBaseUrl?: string;
|
||||
isCompatible?: boolean;
|
||||
isAnthropic?: boolean;
|
||||
isCcCompatible?: boolean;
|
||||
isCommandCode?: boolean;
|
||||
commandCodeAuthState?: CommandCodeAuthFlowState;
|
||||
onStartCommandCodeAuth?: () => void;
|
||||
onSave: (data: {
|
||||
name: string;
|
||||
apiKey?: string;
|
||||
priority: number;
|
||||
baseUrl?: string;
|
||||
providerSpecificData?: Record<string, unknown>;
|
||||
}) => Promise<void | unknown>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function AddApiKeyModal({
|
||||
isOpen,
|
||||
provider,
|
||||
providerName,
|
||||
initialBaseUrl,
|
||||
isCompatible,
|
||||
isAnthropic,
|
||||
isCcCompatible,
|
||||
isCommandCode,
|
||||
commandCodeAuthState,
|
||||
onStartCommandCodeAuth,
|
||||
onSave,
|
||||
onClose,
|
||||
}: AddApiKeyModalProps) {
|
||||
const t = useTranslations("providers");
|
||||
const usesBaseUrl = isBaseUrlConfigurableProvider(provider);
|
||||
const defaultBaseUrl = getProviderBaseUrlDefault(provider);
|
||||
const isVertex = provider === "vertex" || provider === "vertex-partner";
|
||||
const isBedrock = provider === "bedrock";
|
||||
const showsRegion = isVertex || isBedrock;
|
||||
const defaultRegion = isBedrock ? "eu-west-2" : "us-central1";
|
||||
const isGlm = isGlmProvider(provider);
|
||||
const isQoder = provider === "qoder";
|
||||
const isCloudflare = provider === "cloudflare-ai";
|
||||
const localProviderMetadata = getLocalProviderMetadata(provider);
|
||||
const isLocalSelfHostedProvider = !!localProviderMetadata;
|
||||
const isGooglePse = provider === "google-pse-search";
|
||||
const webSessionCredential = getWebSessionCredentialRequirement(provider);
|
||||
const isNoAuthWebSessionCredential = webSessionCredential?.kind === "none";
|
||||
const isWebSessionCredential = !!webSessionCredential && webSessionCredential.kind !== "none";
|
||||
const providerDisplayName = providerName || provider || "";
|
||||
const apiKeyOptional =
|
||||
providerAllowsOptionalApiKey(provider) || Boolean(isNoAuthWebSessionCredential);
|
||||
const commandCodeAuthPhaseLabel = commandCodeAuthState
|
||||
? {
|
||||
idle: "Ready",
|
||||
starting: "Starting…",
|
||||
polling: "Waiting for browser…",
|
||||
received: "Browser approved",
|
||||
applying: "Applying key…",
|
||||
applied: "Connected",
|
||||
expired: "Link expired",
|
||||
error: "Connection failed",
|
||||
}[commandCodeAuthState.phase]
|
||||
: null;
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
apiKey: "",
|
||||
priority: 1,
|
||||
baseUrl: initialBaseUrl || defaultBaseUrl,
|
||||
cx: "",
|
||||
region: showsRegion ? defaultRegion : "",
|
||||
apiRegion: "international",
|
||||
validationModelId: "",
|
||||
routingTags: "",
|
||||
excludedModels: "",
|
||||
customUserAgent: "",
|
||||
accountId: "",
|
||||
consoleApiKey: "",
|
||||
ccCompatibleContext1m: false,
|
||||
passthroughModels: false,
|
||||
});
|
||||
const [validating, setValidating] = useState(false);
|
||||
const [validationResult, setValidationResult] = useState(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [copiedCommandCodeField, setCopiedCommandCodeField] = useState<string | null>(null);
|
||||
const wasOpenRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const wasOpen = wasOpenRef.current;
|
||||
wasOpenRef.current = isOpen;
|
||||
if (!isOpen || wasOpen) return;
|
||||
setFormData((current) => ({
|
||||
...current,
|
||||
baseUrl: initialBaseUrl || defaultBaseUrl,
|
||||
}));
|
||||
}, [defaultBaseUrl, initialBaseUrl, isOpen]);
|
||||
|
||||
const bulkSupported = supportsBulkApiKey(provider);
|
||||
const [mode, setMode] = useState<"single" | "bulk">("single");
|
||||
const [bulkText, setBulkText] = useState("");
|
||||
const [bulkValidateKeys, setBulkValidateKeys] = useState(false);
|
||||
const [bulkResult, setBulkResult] = useState<{
|
||||
success: number;
|
||||
failed: number;
|
||||
total: number;
|
||||
errors: Array<{ index: number; name: string; message: string }>;
|
||||
} | null>(null);
|
||||
const [bulkWarnings, setBulkWarnings] = useState<string[]>([]);
|
||||
const apiCredentialLabel = isQoder
|
||||
? t("personalAccessTokenLabel")
|
||||
: webSessionCredential
|
||||
? getWebSessionCredentialLabel(t, webSessionCredential, apiKeyOptional)
|
||||
: apiKeyOptional
|
||||
? `${t("apiKeyLabel")} (${t("optional").toLowerCase()})`
|
||||
: t("apiKeyLabel");
|
||||
const apiCredentialPlaceholder = isVertex
|
||||
? t("vertexServiceAccountPlaceholder")
|
||||
: isWebSessionCredential
|
||||
? webSessionCredential.placeholder
|
||||
: isQoder
|
||||
? t("qoderPatPlaceholder")
|
||||
: apiKeyOptional
|
||||
? t("optional")
|
||||
: undefined;
|
||||
const apiCredentialHint = isQoder
|
||||
? t("qoderPatHint")
|
||||
: isWebSessionCredential
|
||||
? getWebSessionCredentialHint(t, webSessionCredential, providerDisplayName, false)
|
||||
: isLocalSelfHostedProvider
|
||||
? t("localProviderApiKeyOptionalHint", {
|
||||
provider: localProviderMetadata?.name || providerName || provider || "",
|
||||
})
|
||||
: apiKeyOptional
|
||||
? t("apiKeyOptionalHint")
|
||||
: undefined;
|
||||
const credentialValidationFailedMessage = isWebSessionCredential
|
||||
? providerText(
|
||||
t,
|
||||
"webSessionCredentialValidationFailed",
|
||||
"Session credential validation failed. Sign in again, copy a fresh credential, and try again."
|
||||
)
|
||||
: t("apiKeyValidationFailed");
|
||||
|
||||
const handleValidate = async () => {
|
||||
setValidating(true);
|
||||
setSaveError(null);
|
||||
try {
|
||||
const credentialInput = isCommandCode
|
||||
? extractCommandCodeCredentialInput(formData.apiKey)
|
||||
: formData.apiKey;
|
||||
const res = await fetch("/api/providers/validate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
provider,
|
||||
apiKey: credentialInput,
|
||||
validationModelId: formData.validationModelId || undefined,
|
||||
customUserAgent: formData.customUserAgent.trim() || undefined,
|
||||
baseUrl: formData.baseUrl.trim() || undefined,
|
||||
region: showsRegion ? formData.region.trim() || defaultRegion : undefined,
|
||||
cx: formData.cx.trim() || undefined,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
setValidationResult(data.valid ? "success" : "failed");
|
||||
} catch {
|
||||
setValidationResult("failed");
|
||||
} finally {
|
||||
setValidating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyCommandCodeValue = async (value: string | undefined, key: string) => {
|
||||
if (!value) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopiedCommandCodeField(key);
|
||||
window.setTimeout(() => setCopiedCommandCodeField(null), 1500);
|
||||
} catch {
|
||||
setSaveError("Copy failed. Select the text and copy it manually.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const credentialInput = isCommandCode
|
||||
? extractCommandCodeCredentialInput(formData.apiKey)
|
||||
: formData.apiKey;
|
||||
if (!provider || (!isCompatible && !apiKeyOptional && !credentialInput)) return;
|
||||
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
try {
|
||||
if (isGooglePse && !formData.cx.trim()) {
|
||||
setSaveError(t("searchEngineIdRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
let validatedBaseUrl = null;
|
||||
if (usesBaseUrl) {
|
||||
const checked = normalizeAndValidateHttpBaseUrl(formData.baseUrl, defaultBaseUrl);
|
||||
if (checked.error) {
|
||||
setSaveError(checked.error);
|
||||
return;
|
||||
}
|
||||
validatedBaseUrl = checked.value;
|
||||
}
|
||||
|
||||
let isValid = Boolean(isNoAuthWebSessionCredential && !credentialInput);
|
||||
let validationError: string | null = null;
|
||||
if (!isValid) {
|
||||
try {
|
||||
setValidating(true);
|
||||
setValidationResult(null);
|
||||
const res = await fetch("/api/providers/validate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
provider,
|
||||
apiKey: credentialInput,
|
||||
validationModelId: formData.validationModelId || undefined,
|
||||
customUserAgent: formData.customUserAgent.trim() || undefined,
|
||||
baseUrl: formData.baseUrl.trim() || undefined,
|
||||
region: showsRegion ? formData.region.trim() || defaultRegion : undefined,
|
||||
cx: formData.cx.trim() || undefined,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
isValid = !!data.valid;
|
||||
if (!isValid && data.error) {
|
||||
validationError = data.error;
|
||||
}
|
||||
setValidationResult(isValid ? "success" : "failed");
|
||||
} catch {
|
||||
setValidationResult("failed");
|
||||
} finally {
|
||||
setValidating(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isValid) {
|
||||
if (apiKeyOptional && !credentialInput) {
|
||||
// Bypass validation block for local/optional providers when no key is provided
|
||||
console.debug("Validation failed but apiKey is optional; proceeding to save.");
|
||||
} else {
|
||||
setSaveError(validationError || credentialValidationFailedMessage);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const providerSpecificData: Record<string, unknown> = {};
|
||||
if (formData.customUserAgent.trim()) {
|
||||
providerSpecificData.customUserAgent = formData.customUserAgent.trim();
|
||||
}
|
||||
if (formData.routingTags.trim()) {
|
||||
providerSpecificData.tags = parseRoutingTagsInput(formData.routingTags);
|
||||
}
|
||||
if (formData.excludedModels.trim()) {
|
||||
providerSpecificData.excludedModels = parseExcludedModelsInput(formData.excludedModels);
|
||||
}
|
||||
if (formData.passthroughModels) {
|
||||
providerSpecificData.passthroughModels = true;
|
||||
}
|
||||
if (provider === "bailian-coding-plan" && formData.consoleApiKey.trim()) {
|
||||
providerSpecificData.consoleApiKey = formData.consoleApiKey.trim();
|
||||
}
|
||||
if (isGooglePse && formData.cx.trim()) {
|
||||
providerSpecificData.cx = formData.cx.trim();
|
||||
}
|
||||
if (usesBaseUrl) {
|
||||
providerSpecificData.baseUrl = validatedBaseUrl;
|
||||
} else if (showsRegion) {
|
||||
providerSpecificData.region = formData.region.trim() || defaultRegion;
|
||||
} else if (isGlm) {
|
||||
providerSpecificData.apiRegion = formData.apiRegion;
|
||||
} else if (isCloudflare && formData.accountId.trim()) {
|
||||
providerSpecificData.accountId = formData.accountId.trim();
|
||||
}
|
||||
if (isCcCompatible && formData.ccCompatibleContext1m) {
|
||||
providerSpecificData.requestDefaults = { context1m: true };
|
||||
}
|
||||
|
||||
const payload = {
|
||||
name: formData.name,
|
||||
apiKey: credentialInput.trim() || undefined,
|
||||
priority: formData.priority,
|
||||
testStatus: "active",
|
||||
providerSpecificData:
|
||||
Object.keys(providerSpecificData).length > 0 ? providerSpecificData : undefined,
|
||||
};
|
||||
|
||||
const error = await onSave(payload);
|
||||
if (error) {
|
||||
setSaveError(typeof error === "string" ? error : t("failedSaveConnection"));
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkSubmit = async () => {
|
||||
if (!provider) return;
|
||||
const parsed = parseBulkApiKeys(bulkText);
|
||||
setBulkWarnings(parsed.warnings);
|
||||
if (parsed.entries.length === 0) return;
|
||||
|
||||
setSaving(true);
|
||||
setBulkResult(null);
|
||||
setSaveError(null);
|
||||
|
||||
try {
|
||||
let providerSpecificData: Record<string, unknown> | undefined;
|
||||
if (usesBaseUrl) {
|
||||
const checked = normalizeAndValidateHttpBaseUrl(formData.baseUrl, defaultBaseUrl);
|
||||
if (checked.error) {
|
||||
setSaveError(checked.error);
|
||||
return;
|
||||
}
|
||||
providerSpecificData = { baseUrl: checked.value };
|
||||
}
|
||||
|
||||
const res = await fetch("/api/providers/bulk", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
provider,
|
||||
entries: parsed.entries.map((e) => ({ name: e.name, apiKey: e.apiKey })),
|
||||
priority: formData.priority || 1,
|
||||
providerSpecificData,
|
||||
validateKeys: bulkValidateKeys,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setSaveError(typeof data?.error === "string" ? data.error : t("failedSaveConnection"));
|
||||
return;
|
||||
}
|
||||
setBulkResult({
|
||||
success: data.success || 0,
|
||||
failed: data.failed || 0,
|
||||
total: data.total || 0,
|
||||
errors: Array.isArray(data.errors) ? data.errors : [],
|
||||
});
|
||||
} catch (err) {
|
||||
setSaveError(err instanceof Error ? err.message : t("failedSaveConnection"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!provider) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
title={getAddCredentialModalTitle(t, providerDisplayName, webSessionCredential)}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{bulkSupported && (
|
||||
<div className="flex gap-1 border-b border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMode("single");
|
||||
setBulkResult(null);
|
||||
setBulkWarnings([]);
|
||||
}}
|
||||
className={`px-3 py-1.5 text-sm font-medium transition-colors ${
|
||||
mode === "single"
|
||||
? "border-b-2 border-primary text-text-main"
|
||||
: "text-text-muted hover:text-text-main"
|
||||
}`}
|
||||
>
|
||||
{t("bulkTabSingle")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMode("bulk");
|
||||
setSaveError(null);
|
||||
}}
|
||||
className={`px-3 py-1.5 text-sm font-medium transition-colors ${
|
||||
mode === "bulk"
|
||||
? "border-b-2 border-primary text-text-main"
|
||||
: "text-text-muted hover:text-text-main"
|
||||
}`}
|
||||
>
|
||||
{t("bulkTabBulkAdd")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bulkSupported && mode === "bulk" && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-xs text-text-muted">{t("bulkAddFormatHint")}</p>
|
||||
<textarea
|
||||
className="w-full rounded border border-border bg-background p-2 text-sm font-mono resize-y min-h-[140px] focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
placeholder={"name1|sk-key1\nname2|sk-key2\nsk-key-only-auto-named"}
|
||||
value={bulkText}
|
||||
onChange={(e) => setBulkText(e.target.value)}
|
||||
/>
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm text-text-muted">{t("priorityLabel")}</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={formData.priority}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
priority: Number.parseInt(e.target.value) || 1,
|
||||
})
|
||||
}
|
||||
className="w-20 px-2 py-1 text-sm border border-border rounded bg-background"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm text-text-muted cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={bulkValidateKeys}
|
||||
onChange={(e) => setBulkValidateKeys(e.target.checked)}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
{t("bulkValidateKeys")}
|
||||
</label>
|
||||
</div>
|
||||
{bulkWarnings.length > 0 && (
|
||||
<div className="rounded border border-amber-500/25 bg-amber-500/10 p-2 text-xs text-amber-200 space-y-1">
|
||||
{bulkWarnings.map((w, i) => (
|
||||
<div key={i}>{w}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{bulkResult && (
|
||||
<div
|
||||
className={`text-sm font-medium ${
|
||||
bulkResult.failed > 0 ? "text-amber-300" : "text-emerald-400"
|
||||
}`}
|
||||
>
|
||||
{t("bulkAddedCount", { count: bulkResult.success })}
|
||||
{bulkResult.failed > 0 && (
|
||||
<>, {t("bulkFailedCount", { count: bulkResult.failed })}</>
|
||||
)}
|
||||
{bulkResult.errors.length > 0 && (
|
||||
<ul className="mt-2 list-disc pl-5 text-xs text-text-muted font-normal space-y-0.5">
|
||||
{bulkResult.errors.slice(0, 10).map((err, i) => (
|
||||
<li key={i}>
|
||||
{err.name}: {err.message}
|
||||
</li>
|
||||
))}
|
||||
{bulkResult.errors.length > 10 && (
|
||||
<li>… {bulkResult.errors.length - 10} more</li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{saveError && <div className="text-sm text-rose-400">{saveError}</div>}
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleBulkSubmit} fullWidth disabled={saving || !bulkText.trim()}>
|
||||
{saving ? t("adding") : t("bulkAddAllKeys")}
|
||||
</Button>
|
||||
<Button onClick={onClose} variant="ghost" fullWidth>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(!bulkSupported || mode === "single") && (
|
||||
<>
|
||||
{isCcCompatible && (
|
||||
<div className="rounded-lg border border-amber-500/25 bg-amber-500/10 px-3 py-2 text-sm text-text-muted">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="material-symbols-outlined mt-0.5 text-[18px] text-amber-500">
|
||||
warning
|
||||
</span>
|
||||
<p>{t("ccCompatibleValidationHint")}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isCommandCode && onStartCommandCodeAuth && (
|
||||
<div className="rounded-lg border border-sky-500/20 bg-sky-500/10 px-3 py-3 text-sm">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="material-symbols-outlined mt-0.5 text-[18px] text-sky-500">
|
||||
open_in_new
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-text-main">
|
||||
{t("providerDetailBrowserManualConnect")}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-text-muted">
|
||||
Open Command Code Studio, then paste the returned key/JSON/URL into the API
|
||||
key field below.
|
||||
</p>
|
||||
{commandCodeAuthState?.message && (
|
||||
<p className="mt-2 text-xs text-text-muted">
|
||||
{commandCodeAuthPhaseLabel}: {commandCodeAuthState.message}
|
||||
</p>
|
||||
)}
|
||||
{commandCodeAuthState?.authUrl && (
|
||||
<div className="mt-3 space-y-2">
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-medium text-text-main">
|
||||
{t("providerDetailAuthUrl")}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={commandCodeAuthState.authUrl}
|
||||
readOnly
|
||||
className="flex-1 font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon={copiedCommandCodeField === "authUrl" ? "check" : "content_copy"}
|
||||
onClick={() =>
|
||||
copyCommandCodeValue(commandCodeAuthState.authUrl, "authUrl")
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{commandCodeAuthState.callbackUrl && (
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-medium text-text-main">
|
||||
{t("providerDetailCallbackUrl")}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={commandCodeAuthState.callbackUrl}
|
||||
readOnly
|
||||
className="flex-1 font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon={
|
||||
copiedCommandCodeField === "callbackUrl"
|
||||
? "check"
|
||||
: "content_copy"
|
||||
}
|
||||
onClick={() =>
|
||||
copyCommandCodeValue(
|
||||
commandCodeAuthState.callbackUrl,
|
||||
"callbackUrl"
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon="open_in_new"
|
||||
loading={
|
||||
commandCodeAuthState?.phase === "starting" ||
|
||||
commandCodeAuthState?.phase === "polling" ||
|
||||
commandCodeAuthState?.phase === "applying"
|
||||
}
|
||||
onClick={onStartCommandCodeAuth}
|
||||
>
|
||||
Connect in browser
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Input
|
||||
label={t("nameLabel")}
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder={isQoder ? t("personalAccessTokenLabel") : t("productionKey")}
|
||||
/>
|
||||
{webSessionCredential && (
|
||||
<WebSessionCredentialGuide
|
||||
requirement={webSessionCredential}
|
||||
providerName={providerDisplayName}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
{!isNoAuthWebSessionCredential && (
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
label={apiCredentialLabel}
|
||||
type="password"
|
||||
value={formData.apiKey}
|
||||
onChange={(e) => setFormData({ ...formData, apiKey: e.target.value })}
|
||||
className="flex-1"
|
||||
placeholder={apiCredentialPlaceholder}
|
||||
hint={apiCredentialHint}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
autoCapitalize="off"
|
||||
/>
|
||||
<div className="pt-6">
|
||||
<Button
|
||||
onClick={handleValidate}
|
||||
disabled={
|
||||
(!isCompatible && !apiKeyOptional && !formData.apiKey) ||
|
||||
(isGooglePse && !formData.cx.trim()) ||
|
||||
validating ||
|
||||
saving
|
||||
}
|
||||
variant="secondary"
|
||||
>
|
||||
{validating
|
||||
? t("checking")
|
||||
: webSessionCredential
|
||||
? getWebSessionCredentialCheckLabel(t, webSessionCredential)
|
||||
: t("check")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isGooglePse && (
|
||||
<Input
|
||||
label={t("searchEngineIdLabel")}
|
||||
value={formData.cx}
|
||||
onChange={(e) => setFormData({ ...formData, cx: e.target.value })}
|
||||
placeholder="012345678901234567890:abc123xyz"
|
||||
hint={t("searchEngineIdHint")}
|
||||
/>
|
||||
)}
|
||||
{validationResult && (
|
||||
<Badge variant={validationResult === "success" ? "success" : "error"}>
|
||||
{validationResult === "success" ? t("valid") : t("invalid")}
|
||||
</Badge>
|
||||
)}
|
||||
{saveError && (
|
||||
<div className="text-sm text-red-500 bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2">
|
||||
{saveError}
|
||||
</div>
|
||||
)}
|
||||
{isCcCompatible && (
|
||||
<div className="flex flex-col gap-4 rounded-lg border border-border/50 bg-surface/20 p-4">
|
||||
<Toggle
|
||||
checked={formData.ccCompatibleContext1m}
|
||||
onChange={(checked) =>
|
||||
setFormData({ ...formData, ccCompatibleContext1m: checked })
|
||||
}
|
||||
label={t("ccCompatibleContext1mLabel")}
|
||||
description={t("ccCompatibleContext1mDescription")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isCompatible && !isCcCompatible && (
|
||||
<p className="text-xs text-text-muted">
|
||||
{isAnthropic
|
||||
? t("validationChecksAnthropicCompatible", {
|
||||
provider: providerName || t("anthropicCompatibleName"),
|
||||
})
|
||||
: t("validationChecksOpenAiCompatible", {
|
||||
provider: providerName || t("openaiCompatibleName"),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm text-text-muted hover:text-text-primary flex items-center gap-1"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
aria-expanded={showAdvanced}
|
||||
aria-controls="add-api-key-advanced-settings"
|
||||
>
|
||||
<span
|
||||
className={`transition-transform ${showAdvanced ? "rotate-90" : ""}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
▶
|
||||
</span>
|
||||
{t("advancedSettings")}
|
||||
</button>
|
||||
{showAdvanced && (
|
||||
<div
|
||||
id="add-api-key-advanced-settings"
|
||||
className="flex flex-col gap-3 pl-2 border-l-2 border-border"
|
||||
>
|
||||
<Input
|
||||
label={t("customUserAgentLabel")}
|
||||
value={formData.customUserAgent}
|
||||
onChange={(e) => setFormData({ ...formData, customUserAgent: e.target.value })}
|
||||
placeholder="my-app/1.0"
|
||||
hint={t("customUserAgentHint")}
|
||||
/>
|
||||
<Input
|
||||
label={t("routingTagsLabel")}
|
||||
value={formData.routingTags}
|
||||
onChange={(e) => setFormData({ ...formData, routingTags: e.target.value })}
|
||||
placeholder={t("routingTagsPlaceholder")}
|
||||
hint={t("routingTagsHint")}
|
||||
/>
|
||||
<Input
|
||||
label={t("excludedModelsLabel")}
|
||||
value={formData.excludedModels}
|
||||
onChange={(e) => setFormData({ ...formData, excludedModels: e.target.value })}
|
||||
placeholder={t("excludedModelsPlaceholder")}
|
||||
hint={t("excludedModelsHint")}
|
||||
/>
|
||||
<Toggle
|
||||
size="sm"
|
||||
checked={formData.passthroughModels}
|
||||
onChange={(checked) => setFormData({ ...formData, passthroughModels: checked })}
|
||||
label={t("perModelQuotaLabel")}
|
||||
description={t("perModelQuotaDescription")}
|
||||
/>
|
||||
{provider === "bailian-coding-plan" && (
|
||||
<Input
|
||||
label={t("consoleApiKeyOracleLabel")}
|
||||
value={formData.consoleApiKey}
|
||||
onChange={(e) => setFormData({ ...formData, consoleApiKey: e.target.value })}
|
||||
placeholder={t("consoleApiKeyOraclePlaceholder")}
|
||||
hint={t("consoleApiKeyOracleHint")}
|
||||
type="password"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Input
|
||||
label={t("validationModelIdLabel")}
|
||||
placeholder={t("validationModelIdPlaceholder")}
|
||||
value={formData.validationModelId}
|
||||
onChange={(e) => setFormData({ ...formData, validationModelId: e.target.value })}
|
||||
hint={t("validationModelIdHint")}
|
||||
/>
|
||||
<Input
|
||||
label={t("priorityLabel")}
|
||||
type="number"
|
||||
value={formData.priority}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, priority: Number.parseInt(e.target.value) || 1 })
|
||||
}
|
||||
/>
|
||||
{usesBaseUrl && (
|
||||
<Input
|
||||
label={t("baseUrlLabel")}
|
||||
value={formData.baseUrl}
|
||||
onChange={(e) => setFormData({ ...formData, baseUrl: e.target.value })}
|
||||
placeholder={getProviderBaseUrlPlaceholder(provider)}
|
||||
hint={getProviderBaseUrlHint(provider, t)}
|
||||
/>
|
||||
)}
|
||||
{showsRegion && (
|
||||
<Input
|
||||
label={t("regionLabel")}
|
||||
value={formData.region}
|
||||
onChange={(e) => setFormData({ ...formData, region: e.target.value })}
|
||||
placeholder={defaultRegion}
|
||||
hint={t("regionHint")}
|
||||
/>
|
||||
)}
|
||||
{isCloudflare && (
|
||||
<Input
|
||||
label={t("accountIdLabel")}
|
||||
value={formData.accountId}
|
||||
onChange={(e) => setFormData({ ...formData, accountId: e.target.value })}
|
||||
placeholder={t("accountIdPlaceholder")}
|
||||
hint={t("accountIdHint")}
|
||||
/>
|
||||
)}
|
||||
{isGlm && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-text-main mb-1 block">
|
||||
{t("apiRegionLabel")}
|
||||
</label>
|
||||
<select
|
||||
value={formData.apiRegion}
|
||||
onChange={(e) => setFormData({ ...formData, apiRegion: e.target.value })}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
|
||||
>
|
||||
<option value="international">{t("apiRegionInternational")}</option>
|
||||
<option value="china">{t("apiRegionChina")}</option>
|
||||
</select>
|
||||
<p className="text-xs text-text-muted mt-1">{t("apiRegionHint")}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
fullWidth
|
||||
disabled={
|
||||
!formData.name ||
|
||||
(!isCompatible && !apiKeyOptional && !formData.apiKey) ||
|
||||
(isGooglePse && !formData.cx.trim()) ||
|
||||
saving ||
|
||||
(usesBaseUrl && !formData.baseUrl.trim() && !defaultBaseUrl)
|
||||
}
|
||||
>
|
||||
{saving ? t("saving") : t("save")}
|
||||
</Button>
|
||||
<Button onClick={onClose} variant="ghost" fullWidth>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,155 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Phase 1c regression tests for Issue #3501. AddApiKeyModal and EditConnectionModal
|
||||
// were extracted from the god-component. This proves each mounts in isolation with
|
||||
// its clean Props interface (Hard Rule #8, Rule #18 TDD gate).
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import AddApiKeyModal from "../AddApiKeyModal";
|
||||
import EditConnectionModal from "../EditConnectionModal";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useParams: () => ({ id: "openai" }),
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
}));
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: (ns?: string) => (k: string) => (ns ? `${ns}.${k}` : k),
|
||||
}));
|
||||
|
||||
const cleanups: Array<() => void> = [];
|
||||
|
||||
function renderModal(node: React.ReactElement) {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
act(() => root.render(node));
|
||||
cleanups.push(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
return container;
|
||||
}
|
||||
|
||||
describe("conn-modals (Phase 1c extraction)", () => {
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(() =>
|
||||
Promise.resolve({ ok: true, json: async () => ({}), text: async () => "" } as Response)
|
||||
)
|
||||
);
|
||||
vi.stubGlobal("localStorage", {
|
||||
getItem: () => null,
|
||||
setItem: () => undefined,
|
||||
removeItem: () => undefined,
|
||||
clear: () => undefined,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (cleanups.length) cleanups.pop()?.();
|
||||
document.body.innerHTML = "";
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("AddApiKeyModal mounts standalone when isOpen=false", () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
const c = renderModal(
|
||||
<AddApiKeyModal
|
||||
isOpen={false}
|
||||
provider="openai"
|
||||
providerName="OpenAI"
|
||||
isCompatible={false}
|
||||
onSave={onSave}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
// When isOpen=false the modal renders nothing (null body) — no throw is the assertion.
|
||||
expect(c).toBeDefined();
|
||||
});
|
||||
|
||||
it("AddApiKeyModal mounts standalone when isOpen=true", () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
const c = renderModal(
|
||||
<AddApiKeyModal
|
||||
isOpen={true}
|
||||
provider="openai"
|
||||
providerName="OpenAI"
|
||||
isCompatible={false}
|
||||
onSave={onSave}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(c.querySelector("*")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("AddApiKeyModal returns null when provider is falsy", () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
const c = renderModal(
|
||||
<AddApiKeyModal isOpen={true} onSave={onSave} onClose={vi.fn()} />
|
||||
);
|
||||
// No provider → renders null
|
||||
expect(c.textContent).toBe("");
|
||||
});
|
||||
|
||||
it("EditConnectionModal mounts standalone when connection=null", () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
const c = renderModal(
|
||||
<EditConnectionModal
|
||||
isOpen={false}
|
||||
connection={null}
|
||||
onSave={onSave}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
// connection=null → renders null — no throw is the assertion
|
||||
expect(c).toBeDefined();
|
||||
});
|
||||
|
||||
it("EditConnectionModal mounts standalone with a connection", () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
const connection = {
|
||||
id: "conn-1",
|
||||
name: "Test Connection",
|
||||
provider: "openai",
|
||||
authType: "apikey",
|
||||
priority: 1,
|
||||
};
|
||||
const c = renderModal(
|
||||
<EditConnectionModal
|
||||
isOpen={true}
|
||||
connection={connection}
|
||||
onSave={onSave}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(c.querySelector("*")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("EditConnectionModal renders without ReferenceError for oauth connection", () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
const connection = {
|
||||
id: "conn-oauth",
|
||||
name: "OAuth Account",
|
||||
email: "user@example.com",
|
||||
provider: "claude",
|
||||
authType: "oauth",
|
||||
priority: 1,
|
||||
};
|
||||
// Must not throw; tests that ERROR_TYPE_LABELS and formatTimeAgo are properly imported
|
||||
expect(() =>
|
||||
renderModal(
|
||||
<EditConnectionModal
|
||||
isOpen={true}
|
||||
connection={connection}
|
||||
onSave={onSave}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
)
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -629,3 +629,43 @@ export function shouldSwitchToVisibleFilter(opts: {
|
||||
}): boolean {
|
||||
return opts.autoHideFailed && opts.hiddenCount > 0;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error-type label map — shared by ConnectionRow and EditConnectionModal
|
||||
// ---------------------------------------------------------------------------
|
||||
export const ERROR_TYPE_LABELS: Record<
|
||||
string,
|
||||
{ labelKey: string; variant: string }
|
||||
> = {
|
||||
runtime_error: { labelKey: "errorTypeRuntime", variant: "warning" },
|
||||
upstream_auth_error: { labelKey: "errorTypeUpstreamAuth", variant: "error" },
|
||||
account_deactivated: { labelKey: "Account Deactivated", variant: "error" },
|
||||
auth_missing: { labelKey: "errorTypeMissingCredential", variant: "warning" },
|
||||
token_refresh_failed: { labelKey: "errorTypeRefreshFailed", variant: "warning" },
|
||||
token_expired: { labelKey: "errorTypeTokenExpired", variant: "warning" },
|
||||
upstream_rate_limited: { labelKey: "errorTypeRateLimited", variant: "warning" },
|
||||
upstream_unavailable: { labelKey: "errorTypeUpstreamUnavailable", variant: "error" },
|
||||
network_error: { labelKey: "errorTypeNetworkError", variant: "warning" },
|
||||
unsupported: { labelKey: "errorTypeTestUnsupported", variant: "default" },
|
||||
upstream_error: { labelKey: "errorTypeUpstreamError", variant: "error" },
|
||||
banned: { labelKey: "errorTypeBanned", variant: "error" },
|
||||
credits_exhausted: { labelKey: "errorTypeCreditsExhausted", variant: "warning" },
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// formatTimeAgo — used in EditConnectionModal's extra-key health display
|
||||
// ---------------------------------------------------------------------------
|
||||
export function formatTimeAgo(dateStr: string): string {
|
||||
const now = Date.now();
|
||||
const date = new Date(dateStr).getTime();
|
||||
const diff = now - date;
|
||||
if (diff < 0) return "just now";
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
if (minutes < 1) return "just now";
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days < 30) return `${days}d ago`;
|
||||
return new Date(dateStr).toLocaleDateString();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user