mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
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:
committed by
GitHub
parent
bb16f88524
commit
840413faa2
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
51
tests/unit/api-manager-key-visibility.test.ts
Normal file
51
tests/unit/api-manager-key-visibility.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user