diff --git a/src/app/api-keys/page.test.tsx b/src/app/api-keys/page.test.tsx
new file mode 100644
index 0000000..8910ea5
--- /dev/null
+++ b/src/app/api-keys/page.test.tsx
@@ -0,0 +1,70 @@
+import { render, screen, fireEvent, waitFor } from "@testing-library/react";
+import ApiKeysPage from "./page";
+
+const mockItems = [{ prefix: "abc123", label: "my-key", createdAt: 1700000000 }];
+
+function mockFetchSuccess() {
+ globalThis.fetch = jest.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => ({ items: mockItems }),
+ } as unknown as Response);
+}
+
+afterEach(() => jest.restoreAllMocks());
+
+it("does not delete immediately when Revoke is clicked", async () => {
+ mockFetchSuccess();
+ render();
+ await screen.findByText("my-key");
+ const fetchMock = globalThis.fetch as jest.Mock;
+ fetchMock.mockClear();
+
+ fireEvent.click(screen.getByRole("button", { name: /^revoke$/i }));
+ expect(fetchMock).not.toHaveBeenCalled();
+});
+
+it("shows confirm dialog when Revoke is clicked", async () => {
+ mockFetchSuccess();
+ render();
+ await screen.findByText("my-key");
+
+ fireEvent.click(screen.getByRole("button", { name: /^revoke$/i }));
+ expect(screen.getByRole("dialog")).toBeInTheDocument();
+ expect(screen.getByText(/revoke api key/i)).toBeInTheDocument();
+});
+
+it("cancels without deleting when Cancel is clicked", async () => {
+ mockFetchSuccess();
+ render();
+ await screen.findByText("my-key");
+ const fetchMock = globalThis.fetch as jest.Mock;
+ fetchMock.mockClear();
+
+ fireEvent.click(screen.getByRole("button", { name: /^revoke$/i }));
+ fireEvent.click(screen.getByRole("button", { name: /cancel/i }));
+ expect(fetchMock).not.toHaveBeenCalled();
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
+});
+
+it("calls DELETE and closes dialog when confirmed", async () => {
+ mockFetchSuccess();
+ render();
+ await screen.findByText("my-key");
+
+ // stub DELETE + reload
+ (globalThis.fetch as jest.Mock)
+ .mockResolvedValueOnce({ ok: true, status: 204, json: async () => ({}) } as unknown as Response)
+ .mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ items: [] }) } as unknown as Response);
+
+ fireEvent.click(screen.getByRole("button", { name: /^revoke$/i }));
+ // click the Revoke confirm button inside the dialog
+ const confirmBtn = screen.getAllByRole("button", { name: /^revoke$/i })[0];
+ fireEvent.click(confirmBtn);
+
+ await waitFor(() => {
+ const calls = (globalThis.fetch as jest.Mock).mock.calls;
+ expect(calls.some((c: string[]) => c[0].includes("/api/v1/api-keys/abc123"))).toBe(true);
+ });
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
+});
diff --git a/src/app/api-keys/page.tsx b/src/app/api-keys/page.tsx
index df0346f..443d242 100644
--- a/src/app/api-keys/page.tsx
+++ b/src/app/api-keys/page.tsx
@@ -2,6 +2,7 @@
import { useEffect, useState } from "react";
import { apiGet, apiPost, apiDelete } from "@/lib/apiClient";
+import { ConfirmDialog } from "@/components/ConfirmDialog";
type KeyItem = { prefix: string; label: string; createdAt: number };
@@ -10,6 +11,7 @@ export default function ApiKeysPage() {
const [label, setLabel] = useState("");
const [created, setCreated] = useState(null);
const [error, setError] = useState(null);
+ const [pendingRevoke, setPendingRevoke] = useState(null);
const load = () =>
apiGet<{ items: KeyItem[] }>("/api/v1/api-keys")
@@ -48,6 +50,17 @@ export default function ApiKeysPage() {
tabIndex={-1}
className="mx-auto flex min-h-[60vh] max-w-3xl flex-col gap-6 p-8 focus:outline-none"
>
+ {
+ if (pendingRevoke) onDelete(pendingRevoke.prefix);
+ setPendingRevoke(null);
+ }}
+ onCancel={() => setPendingRevoke(null)}
+ />
API keys