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 }],