From 840413faa2240d0ca19ff3305eb15adbe97ea421 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sun, 21 Jun 2026 14:14:41 -0300 Subject: [PATCH] feat(dashboard): inline show/hide toggle for API keys on API Manager page (#4505) Rebuilt onto release/v3.8.33 (squash-base-stale). Integrated into release/v3.8.33. --- CHANGELOG.md | 1 + .../api-manager/ApiManagerPageClient.tsx | 92 ++++++++++++++++--- .../api-manager/apiManagerPageUtils.ts | 21 +++++ tests/unit/api-manager-key-visibility.test.ts | 51 ++++++++++ 4 files changed, 154 insertions(+), 11 deletions(-) create mode 100644 tests/unit/api-manager-key-visibility.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 941f23a16e..540380e5bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ _In development — bullets added per PR; finalized at release._ ### ✨ New Features +- **feat(dashboard): inline show/hide toggle for API keys on the API Manager page** — each row in the API keys list now exposes an eye / eye-off button next to the masked key. Clicking it lazy-fetches the full key via the existing `/api/keys/{id}/reveal` endpoint (so the policy gate is unchanged), caches it client-side, and renders the full value inline; clicking again hides it. The toggle only appears when `allowKeyReveal` is true (server policy), so an installation that disables reveal still sees a locked stub. Reuses the existing i18n keys `apiManager.showKey` / `apiManager.hideKey` already shipped in every locale, and clears the cached reveal when the key is deleted. Inspired-by: toanalien. - **feat(oauth): import accounts from CLIProxyAPI** — Settings → CLIProxyAPI now has an "Import accounts" button that reads the OAuth accounts CLIProxyAPI already saved in `~/.cli-proxy-api/` and imports them as OmniRoute connections, so you don't have to log into every account individually. CLIProxyAPI's unified auth-file format is parsed by `type` discriminator and the supported account types (Gemini, Codex, Claude/Anthropic, Antigravity, Qwen, Kimi) are upserted; unknown types are skipped. The preview never exposes tokens to the client. (thanks @powellnorma) - **feat(routing): opt-in setting to echo the requested alias/combo name in the response model field** — Settings → Routing now has an "Echo requested model name in responses" toggle (default off). When enabled, the response `model` field (non-streaming and every streamed SSE chunk) reports the alias or combo name the client requested instead of the upstream model name, so strict clients such as Claude Desktop — which reject a response whose `model` does not match the request with a 401 — work with aliases and combos. (thanks @thaiphuong1202) - **feat(providers): expand the openai and gemini direct registries with first-class variants already known elsewhere** — the `openai` provider entry now exposes `gpt-4.1-mini`, `gpt-4.1-nano`, `o3-mini`, and `o4-mini` (the latter two carry `REASONING_UNSUPPORTED` like `o3`), and the `gemini` entry now exposes `gemini-2.0-flash-lite` and `gemini-3-flash-lite-preview`. These models were already first-class throughout sibling subsystems (cost estimator, task fitness, free-model catalog, multiple aggregator registries) but happened to be missing from the direct openai/gemini namespaces. Embedding/TTS/image-gen models stay in their dedicated registries (`embeddingRegistry.ts`, `audioRegistry.ts`, `imageRegistry.ts`); legacy ids OmniRoute curated out (o1, gpt-4-turbo, …) are not restored. (thanks @East-rayyy) diff --git a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx index b31e683d2c..03a1f96fe3 100644 --- a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx +++ b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx @@ -16,6 +16,8 @@ import { computeApiKeyCounts, formatUsdCost, toLocalDateTimeInputValue, + maskKey, + toggleKeyVisibility, } from "./apiManagerPageUtils"; import type { KeyStatus, KeyType } from "./apiManagerPageUtils"; import { readActiveOnlyPreference, writeActiveOnlyPreference } from "./apiManagerPageStorage"; @@ -220,6 +222,10 @@ export default function ApiManagerPageClient() { const [usageStats, setUsageStats] = useState>({}); const [sessionCounts, setSessionCounts] = useState>({}); const [allowKeyReveal, setAllowKeyReveal] = useState(false); + // Per-row API key visibility toggle (eye / eye-off). Keys default to masked. + // Map id -> fully revealed key string fetched on demand from /api/keys/{id}/reveal. + const [revealedKeys, setRevealedKeys] = useState>(new Map()); + const [visibleKeys, setVisibleKeys] = useState>(new Set()); const createKeyNameFieldRef = useRef(null); const [searchQuery, setSearchQuery] = useState(""); @@ -570,6 +576,14 @@ export default function ApiManagerPageClient() { const res = await fetch(`/api/keys/${encodeURIComponent(id)}`, { method: "DELETE" }); if (res.ok) { setKeys((prev) => prev.filter((k) => k.id !== id)); + // Clean up any cached reveal/visibility state for this key. + setRevealedKeys((prev) => { + if (!prev.has(id)) return prev; + const next = new Map(prev); + next.delete(id); + return next; + }); + setVisibleKeys((prev) => (prev.has(id) ? toggleKeyVisibility(prev, id) : prev)); } else { const data = await res.json(); setPageError(data.error || t("failedDeleteKey")); @@ -624,6 +638,12 @@ export default function ApiManagerPageClient() { const data = await res.json(); if (typeof data?.key === "string") { + // Cache the revealed value so a subsequent show-toggle does not refetch. + setRevealedKeys((prev) => { + const next = new Map(prev); + next.set(keyId, data.key); + return next; + }); await copy(data.key, `existing_key_${keyId}`); } } catch (error) { @@ -631,6 +651,39 @@ export default function ApiManagerPageClient() { } }; + /** + * Toggle the visibility of one key inline (eye / eye-off button). + * Lazy-fetches the full key from /api/keys/{id}/reveal on the FIRST show, + * then caches it in `revealedKeys` so re-toggling is instant. Hiding only + * flips the visibility set — the cached reveal stays so a re-show is free. + */ + const handleToggleKeyVisibility = async (keyId: string) => { + if (!keyId) return; + const isCurrentlyVisible = visibleKeys.has(keyId); + + if (!isCurrentlyVisible && !revealedKeys.has(keyId)) { + try { + const res = await fetch(`/api/keys/${encodeURIComponent(keyId)}/reveal`); + if (!res.ok) { + console.log("Error revealing key:", await res.text()); + return; + } + const data = await res.json(); + if (typeof data?.key !== "string") return; + setRevealedKeys((prev) => { + const next = new Map(prev); + next.set(keyId, data.key); + return next; + }); + } catch (error) { + console.log("Error revealing key:", error); + return; + } + } + + setVisibleKeys((prev) => toggleKeyVisibility(prev, keyId)); + }; + const handleUpdatePermissions = async ( name: string, allowedModels: string[], @@ -931,18 +984,35 @@ export default function ApiManagerPageClient() {
- {key.key} + + {visibleKeys.has(key.id) + ? (revealedKeys.get(key.id) ?? key.key) + : maskKey(key.key)} + {allowKeyReveal ? ( - + <> + + + ) : ( 0 && amount < 1 ? 4 : 2, }).format(amount); } + +/** + * Mask a fully revealed API key for the at-rest display: keep the first 8 chars + * (provider prefix + a few entropy bits, e.g. `sk-or-12...`), append an ellipsis. + * Returns "" for empty/missing input so the UI can render an empty `` cleanly. + */ +export function maskKey(fullKey: string | null | undefined): string { + if (!fullKey) return ""; + return fullKey.length > 8 ? `${fullKey.slice(0, 8)}...` : fullKey; +} + +/** + * Immutable Set toggle helper for the "which keys are currently revealed" state. + * Returns a NEW Set so React state setters always see a fresh reference. + */ +export function toggleKeyVisibility(prev: Set, keyId: string): Set { + const next = new Set(prev); + if (next.has(keyId)) next.delete(keyId); + else next.add(keyId); + return next; +} diff --git a/tests/unit/api-manager-key-visibility.test.ts b/tests/unit/api-manager-key-visibility.test.ts new file mode 100644 index 0000000000..261426c3e0 --- /dev/null +++ b/tests/unit/api-manager-key-visibility.test.ts @@ -0,0 +1,51 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + maskKey, + toggleKeyVisibility, +} from "../../src/app/(dashboard)/dashboard/api-manager/apiManagerPageUtils.js"; + +describe("maskKey", () => { + it("returns empty string when key is missing or empty", () => { + assert.equal(maskKey(""), ""); + assert.equal(maskKey(null), ""); + assert.equal(maskKey(undefined), ""); + }); + + it("returns the key untouched when it fits the visible budget (<=8 chars)", () => { + assert.equal(maskKey("sk"), "sk"); + assert.equal(maskKey("sk-12345"), "sk-12345"); + }); + + it("keeps the first 8 chars and appends an ellipsis when the key is longer", () => { + const full = "sk-or-1234567890abcdef"; + const masked = maskKey(full); + assert.equal(masked.startsWith("sk-or-12"), true); + assert.equal(masked.endsWith("..."), true); + // Must not leak the tail + assert.equal(masked.includes("90abcdef"), false); + }); +}); + +describe("toggleKeyVisibility", () => { + it("adds an id when it is not present", () => { + const next = toggleKeyVisibility(new Set(), "k1"); + assert.equal(next.has("k1"), true); + assert.equal(next.size, 1); + }); + + it("removes an id when it is already present", () => { + const next = toggleKeyVisibility(new Set(["k1", "k2"]), "k1"); + assert.equal(next.has("k1"), false); + assert.equal(next.has("k2"), true); + assert.equal(next.size, 1); + }); + + it("returns a NEW Set (does not mutate the input — React state safety)", () => { + const input = new Set(["k1"]); + const output = toggleKeyVisibility(input, "k2"); + assert.notEqual(output, input); + assert.equal(input.has("k2"), false); + assert.equal(output.has("k2"), true); + }); +});