diff --git a/gui/src/components/apikeys-workspace/ApiKeysWorkspace.tsx b/gui/src/components/apikeys-workspace/ApiKeysWorkspace.tsx
index 623e970ea38..21198082162 100644
--- a/gui/src/components/apikeys-workspace/ApiKeysWorkspace.tsx
+++ b/gui/src/components/apikeys-workspace/ApiKeysWorkspace.tsx
@@ -144,9 +144,18 @@ export default function ApiKeysWorkspace({
const [rotationFailed, setRotationFailed] = useState(false);
const selected = selectedId ? (keys.find(k => k.id === selectedId) ?? null) : null;
+ const selectedHasRotationSecret = Boolean(selected && rotationSecret?.id === selected.id);
const selectedRotationId = selected
? (rotationSecret?.id === selected.id ? rotationSecret.rotationId : selected.pendingRotation?.id)
: undefined;
+ // Each handler is independently optional, so "enabled" holds only when the
+ // key's current state has an action the caller wired: start for an idle key,
+ // commit/abort for a pending one. A revealed one-time secret counts on its
+ // own — hiding the section then would strand the only copy. Rendering the
+ // rest offers operations that can only fail locally.
+ const rotationEnabled = selectedHasRotationSecret || (selectedRotationId
+ ? Boolean(onRotationCommit || onRotationAbort)
+ : Boolean(onRotationStart));
const mutationPending = deleting || renamePending || rotationPending;
const runRotation = async (operation: "start" | "commit" | "abort") => {
@@ -365,7 +374,7 @@ export default function ApiKeysWorkspace({
-
+ {rotationEnabled &&
{t("api.rotation.title")}
{selectedRotationId ? (
<>
@@ -378,21 +387,31 @@ export default function ApiKeysWorkspace({
{t("api.rotation.secretOnce")}
{rotationSecret.key}
-
-
+ {onCopyRotationSecret && (
+
+ )}
+ {onDismissRotationSecret && (
+
+ )}
)}
-
-
-
-
+ {(onRotationCommit || onRotationAbort) && (
+
+ {onRotationCommit && (
+
+ )}
+ {onRotationAbort && (
+
+ )}
+
+ )}
>
) : (
<>
@@ -403,7 +422,7 @@ export default function ApiKeysWorkspace({
>
)}
{rotationFailed &&
{t("api.rotation.failed")}
}
-
+ }
{t("api.attribution.title")}
diff --git a/gui/tests/apikeys-actions.test.tsx b/gui/tests/apikeys-actions.test.tsx
index 02015a3b1d7..0b5bd5fc0b6 100644
--- a/gui/tests/apikeys-actions.test.tsx
+++ b/gui/tests/apikeys-actions.test.tsx
@@ -27,6 +27,7 @@ const globals = ["document", "window", "navigator", "localStorage", "IS_REACT_AC
let previousGlobals: Record<(typeof globals)[number], unknown>;
let testWindow: Window;
let active: Root | null = null;
+let rerender: (props: Partial
) => Promise = async () => {};
beforeEach(() => {
previousGlobals = Object.fromEntries(globals.map(key => [key, Reflect.get(globalThis, key)])) as typeof previousGlobals;
@@ -44,6 +45,7 @@ afterEach(async () => {
if (active) {
const root = active;
active = null;
+ rerender = async () => {};
await act(async () => { root.unmount(); });
}
testWindow.close();
@@ -105,6 +107,13 @@ async function mount(props: Partial): Promise {
+ await act(async () => {
+ root.render();
+ });
+ };
await act(async () => { root.render(); });
return container;
}
@@ -284,6 +293,128 @@ test("rotation start, one-time secret, commit, and abort stay explicit", async (
expect(calls).toContain("abort:k1:rotation-1");
});
+test("rotation controls stay hidden when the runtime supplies no rotation handlers", async () => {
+ const container = await mount({});
+ await openKey(container);
+
+ expect(container.textContent).not.toContain("Key rotation");
+ expect(container.textContent).not.toContain("Start rotation");
+ expect(container.textContent).not.toContain("Commit rotation");
+ expect(container.textContent).not.toContain("Abort rotation");
+});
+
+test("an idle key offers rotation only when its start handler is wired", async () => {
+ // Commit/abort without start: the only action an idle key can take has no
+ // handler, so the whole section hides — a visible Start could only fail.
+ const container = await mount({
+ onRotationCommit: async () => true,
+ onRotationAbort: async () => true,
+ });
+ await openKey(container);
+
+ expect(container.textContent).not.toContain("Key rotation");
+ expect(container.textContent).not.toContain("Start rotation");
+});
+
+test("a start-only integration keeps the issued secret on screen", async () => {
+ const calls: string[] = [];
+ const container = await mount({
+ onRotationStart: async id => { calls.push(`start:${id}`); return true; },
+ });
+ await openKey(container);
+ await act(async () => { button(container, "Start rotation").click(); await Promise.resolve(); });
+ expect(calls).toEqual(["start:k1"]);
+
+ // The hub's answer carries the one-time secret. Without finish handlers the
+ // pending-state guard alone would hide the section — stranding the only copy.
+ await rerender({
+ rotationSecret: { id: "k1", key: "ocx_data_shown_once", rotationId: "rotation-1" },
+ });
+ expect(container.textContent).toContain("Key rotation");
+ expect(container.textContent).toContain("ocx_data_shown_once");
+ expect(container.textContent).not.toContain("Commit rotation");
+ expect(container.textContent).not.toContain("Abort rotation");
+});
+
+test("the secret's own controls are wired separately from the lifecycle actions", async () => {
+ const pendingKey = {
+ id: "k1",
+ name: "alpha",
+ prefix: "ocx_data_aaaaaaaa...",
+ createdAt: "2026-01-01T00:00:00.000Z",
+ pendingRotation: {
+ id: "rotation-1",
+ createdAt: "2026-08-28T00:00:00.000Z",
+ expiresAt: "2026-08-28T00:10:00.000Z",
+ },
+ usage: { requests7d: 0, totalRequests: 0 },
+ };
+ const rotationSecret = { id: "k1", key: "ocx_data_shown_once", rotationId: "rotation-1" };
+
+ // A finish handler keeps the section up, but Copy and Close still check
+ // their own callbacks — an unwired reveal box is read-only.
+ const container = await mount({
+ keys: [pendingKey],
+ rotationSecret,
+ onRotationCommit: async () => true,
+ });
+ await openKey(container);
+ const reveal = container.querySelector(".api-key-reveal")!;
+ expect(reveal.textContent).toContain("ocx_data_shown_once");
+ expect([...reveal.querySelectorAll("button")]).toHaveLength(0);
+
+ await act(async () => { active?.unmount(); active = null; });
+
+ const wired = await mount({
+ keys: [pendingKey],
+ rotationSecret,
+ onRotationCommit: async () => true,
+ onCopyRotationSecret: () => {},
+ onDismissRotationSecret: () => {},
+ });
+ await openKey(wired);
+ const wiredReveal = wired.querySelector(".api-key-reveal")!;
+ const labels = [...wiredReveal.querySelectorAll("button")].map(b => b.textContent?.trim());
+ expect(labels).toEqual(["Copy", "Close"]);
+});
+
+test("a pending key renders only the rotation actions that have handlers", async () => {
+ const pendingKey = {
+ id: "k1",
+ name: "alpha",
+ prefix: "ocx_data_aaaaaaaa...",
+ createdAt: "2026-01-01T00:00:00.000Z",
+ pendingRotation: {
+ id: "rotation-1",
+ createdAt: "2026-08-28T00:00:00.000Z",
+ expiresAt: "2026-08-28T00:10:00.000Z",
+ },
+ usage: { requests7d: 0, totalRequests: 0 },
+ };
+
+ // Commit without abort: Commit renders, Abort does not.
+ const container = await mount({
+ keys: [pendingKey],
+ onRotationCommit: async () => true,
+ });
+ await openKey(container);
+ expect(container.textContent).toContain("Commit rotation");
+ expect(container.textContent).not.toContain("Abort rotation");
+
+ await act(async () => { active?.unmount(); active = null; });
+
+ // Start without commit/abort: no action applies to a pending key, so the
+ // section hides rather than offering a Start that cannot help it.
+ const startOnly = await mount({
+ keys: [pendingKey],
+ onRotationStart: async () => true,
+ });
+ await openKey(startOnly);
+ expect(startOnly.textContent).not.toContain("Key rotation");
+ expect(startOnly.textContent).not.toContain("Commit rotation");
+ expect(startOnly.textContent).not.toContain("Abort rotation");
+});
+
test("a protocol result belongs to its own chip", async () => {
const container = await mount({
filteredModels: [{ id: "gpt-5.5", displayName: "gpt-5.5", provider: "openai", native: true }],