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.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-21 14:14:41 -03:00
committed by GitHub
parent bb16f88524
commit 840413faa2
4 changed files with 154 additions and 11 deletions

View File

@@ -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)

View File

@@ -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<Record<string, KeyUsageStats>>({});
const [sessionCounts, setSessionCounts] = useState<Record<string, number>>({});
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<Map<string, string>>(new Map());
const [visibleKeys, setVisibleKeys] = useState<Set<string>>(new Set());
const createKeyNameFieldRef = useRef<HTMLDivElement | null>(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() {
</span>
</div>
<div className="col-span-3 flex items-center gap-1.5">
<code className="text-sm text-text-muted font-mono truncate">{key.key}</code>
<code className="text-sm text-text-muted font-mono truncate">
{visibleKeys.has(key.id)
? (revealedKeys.get(key.id) ?? key.key)
: maskKey(key.key)}
</code>
{allowKeyReveal ? (
<button
onClick={() => handleCopyExistingKey(key.id)}
className="p-1 text-text-muted/60 hover:text-primary transition-colors shrink-0"
title={tc("copy")}
aria-label={tc("copy")}
>
<span className="material-symbols-outlined text-[14px]">
{copied === `existing_key_${key.id}` ? "check" : "content_copy"}
</span>
</button>
<>
<button
onClick={() => handleToggleKeyVisibility(key.id)}
className="p-1 text-text-muted/60 hover:text-primary transition-colors shrink-0"
title={visibleKeys.has(key.id) ? t("hideKey") : t("showKey")}
aria-label={visibleKeys.has(key.id) ? t("hideKey") : t("showKey")}
aria-pressed={visibleKeys.has(key.id)}
>
<span className="material-symbols-outlined text-[14px]">
{visibleKeys.has(key.id) ? "visibility_off" : "visibility"}
</span>
</button>
<button
onClick={() => handleCopyExistingKey(key.id)}
className="p-1 text-text-muted/60 hover:text-primary transition-colors shrink-0"
title={tc("copy")}
aria-label={tc("copy")}
>
<span className="material-symbols-outlined text-[14px]">
{copied === `existing_key_${key.id}` ? "check" : "content_copy"}
</span>
</button>
</>
) : (
<span
className="p-1 text-text-muted/40 opacity-0 group-hover:opacity-100 transition-all shrink-0 cursor-help"

View File

@@ -103,3 +103,24 @@ export function formatUsdCost(value: number, locale: string): string {
maximumFractionDigits: amount > 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 `<code>` 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<string>, keyId: string): Set<string> {
const next = new Set(prev);
if (next.has(keyId)) next.delete(keyId);
else next.add(keyId);
return next;
}

View File

@@ -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<string>(), "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<string>(["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<string>(["k1"]);
const output = toggleKeyVisibility(input, "k2");
assert.notEqual(output, input);
assert.equal(input.has("k2"), false);
assert.equal(output.has("k2"), true);
});
});