Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 33 additions & 14 deletions gui/src/components/apikeys-workspace/ApiKeysWorkspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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") => {
Expand Down Expand Up @@ -365,7 +374,7 @@ export default function ApiKeysWorkspace({
</div>
</dl>
</div>
<div className="awi-section" aria-live="polite">
{rotationEnabled && <div className="awi-section" aria-live="polite">
<h3 className="awi-section-title">{t("api.rotation.title")}</h3>
{selectedRotationId ? (
<>
Expand All @@ -378,21 +387,31 @@ export default function ApiKeysWorkspace({
<p>{t("api.rotation.secretOnce")}</p>
<code>{rotationSecret.key}</code>
<span>
<button type="button" className="btn btn-sm" onClick={onCopyRotationSecret}>
{rotationCopied ? t("api.copied") : t("api.copy")}
</button>
<button type="button" className="btn btn-ghost btn-sm" onClick={onDismissRotationSecret}>{t("common.close")}</button>
{onCopyRotationSecret && (
<button type="button" className="btn btn-sm" onClick={onCopyRotationSecret}>
{rotationCopied ? t("api.copied") : t("api.copy")}
</button>
)}
{onDismissRotationSecret && (
<button type="button" className="btn btn-ghost btn-sm" onClick={onDismissRotationSecret}>{t("common.close")}</button>
)}
</span>
</div>
)}
<div className="awi-detail-actions">
<button type="button" className="btn btn-sm" disabled={rotationPending} onClick={() => { void runRotation("commit"); }}>
{t("api.rotation.commit")}
</button>
<button type="button" className="btn btn-ghost btn-sm" disabled={rotationPending} onClick={() => { void runRotation("abort"); }}>
{t("api.rotation.abort")}
</button>
</div>
{(onRotationCommit || onRotationAbort) && (
<div className="awi-detail-actions">
{onRotationCommit && (
<button type="button" className="btn btn-sm" disabled={rotationPending} onClick={() => { void runRotation("commit"); }}>
{t("api.rotation.commit")}
</button>
)}
{onRotationAbort && (
<button type="button" className="btn btn-ghost btn-sm" disabled={rotationPending} onClick={() => { void runRotation("abort"); }}>
{t("api.rotation.abort")}
</button>
)}
</div>
)}
</>
) : (
<>
Expand All @@ -403,7 +422,7 @@ export default function ApiKeysWorkspace({
</>
)}
{rotationFailed && <p className="awi-delete-error" role="alert">{t("api.rotation.failed")}</p>}
</div>
</div>}
<div className="awi-section">
<h3 className="awi-section-title">{t("api.attribution.title")}</h3>
<UsageIncompleteNotice data={usageMetadata} />
Expand Down
131 changes: 131 additions & 0 deletions gui/tests/apikeys-actions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ApiKeysWorkspaceProps>) => Promise<void> = async () => {};

beforeEach(() => {
previousGlobals = Object.fromEntries(globals.map(key => [key, Reflect.get(globalThis, key)])) as typeof previousGlobals;
Expand All @@ -44,6 +45,7 @@ afterEach(async () => {
if (active) {
const root = active;
active = null;
rerender = async () => {};
await act(async () => { root.unmount(); });
}
testWindow.close();
Expand Down Expand Up @@ -105,6 +107,13 @@ async function mount(props: Partial<ApiKeysWorkspaceProps>): Promise<HTMLDivElem
const { createRoot } = await import("react-dom/client");
const root = createRoot(container);
active = root;
// Same root, new props: how a parent's state update (the rotationSecret
// landing after a start, say) actually reaches the mounted workspace.
rerender = async next => {
await act(async () => {
root.render(<LanguageProvider><ApiKeysWorkspace {...value} {...next} /></LanguageProvider>);
});
};
await act(async () => { root.render(<LanguageProvider><ApiKeysWorkspace {...value} /></LanguageProvider>); });
return container;
}
Expand Down Expand Up @@ -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<HTMLElement>(".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<HTMLElement>(".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 }],
Expand Down
Loading