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

@@ -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;
}