Skip to content
Merged
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
70 changes: 70 additions & 0 deletions src/app/api-keys/page.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<ApiKeysPage />);
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(<ApiKeysPage />);
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(<ApiKeysPage />);
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(<ApiKeysPage />);
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();
});
15 changes: 14 additions & 1 deletion src/app/api-keys/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand All @@ -10,6 +11,7 @@ export default function ApiKeysPage() {
const [label, setLabel] = useState("");
const [created, setCreated] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [pendingRevoke, setPendingRevoke] = useState<KeyItem | null>(null);

const load = () =>
apiGet<{ items: KeyItem[] }>("/api/v1/api-keys")
Expand Down Expand Up @@ -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"
>
<ConfirmDialog
open={pendingRevoke !== null}
title="Revoke API key?"
description={`"${pendingRevoke?.label}" will stop working immediately.`}
confirmLabel="Revoke"
onConfirm={() => {
if (pendingRevoke) onDelete(pendingRevoke.prefix);
setPendingRevoke(null);
}}
onCancel={() => setPendingRevoke(null)}
/>
<h1 className="text-3xl font-semibold tracking-tight">API keys</h1>
<form onSubmit={onCreate} className="flex gap-2">
<input
Expand Down Expand Up @@ -93,7 +106,7 @@ export default function ApiKeysPage() {
</div>
<button
type="button"
onClick={() => onDelete(k.prefix)}
onClick={() => setPendingRevoke(k)}
className="rounded border border-zinc-300 px-3 py-1 text-xs hover:border-rose-500 hover:text-rose-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 dark:border-zinc-700"
>
Revoke
Expand Down
71 changes: 71 additions & 0 deletions src/app/webhooks/page.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import WebhooksPage from "./page";

const mockItems = [
{ id: "wh_1", url: "https://example.com/hook", events: ["usage.recorded"], 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 Remove is clicked", async () => {
mockFetchSuccess();
render(<WebhooksPage />);
await screen.findByText("https://example.com/hook");
const fetchMock = globalThis.fetch as jest.Mock;
fetchMock.mockClear();

fireEvent.click(screen.getByRole("button", { name: /^remove$/i }));
expect(fetchMock).not.toHaveBeenCalled();
});

it("shows confirm dialog when Remove is clicked", async () => {
mockFetchSuccess();
render(<WebhooksPage />);
await screen.findByText("https://example.com/hook");

fireEvent.click(screen.getByRole("button", { name: /^remove$/i }));
expect(screen.getByRole("dialog")).toBeInTheDocument();
expect(screen.getByText(/remove webhook/i)).toBeInTheDocument();
});

it("cancels without deleting when Cancel is clicked", async () => {
mockFetchSuccess();
render(<WebhooksPage />);
await screen.findByText("https://example.com/hook");
const fetchMock = globalThis.fetch as jest.Mock;
fetchMock.mockClear();

fireEvent.click(screen.getByRole("button", { name: /^remove$/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(<WebhooksPage />);
await screen.findByText("https://example.com/hook");

// 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: /^remove$/i }));
const confirmBtn = screen.getAllByRole("button", { name: /^remove$/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/webhooks/wh_1"))).toBe(true);
});
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
26 changes: 23 additions & 3 deletions src/app/webhooks/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { useEffect, useState } from "react";
import { apiGet, apiPost, apiDelete } from "@/lib/apiClient";
import { ConfirmDialog } from "@/components/ConfirmDialog";

type Webhook = { id: string; url: string; events: string[]; createdAt: number };

Expand All @@ -10,6 +11,7 @@ export default function WebhooksPage() {
const [url, setUrl] = useState("");
const [eventsCsv, setEventsCsv] = useState("usage.recorded,usage.settled");
const [error, setError] = useState<string | null>(null);
const [pendingRemove, setPendingRemove] = useState<Webhook | null>(null);

const load = () =>
apiGet<{ items: Webhook[] }>("/api/v1/webhooks")
Expand All @@ -35,12 +37,32 @@ export default function WebhooksPage() {
}
};

const onDelete = async (id: string) => {
try {
await apiDelete(`/api/v1/webhooks/${id}`);
await load();
} catch (err) {
setError((err as Error).message);
}
};

return (
<main
id="main-content"
tabIndex={-1}
className="mx-auto flex min-h-[60vh] max-w-3xl flex-col gap-6 p-8 focus:outline-none"
>
<ConfirmDialog
open={pendingRemove !== null}
title="Remove webhook?"
description={`Deliveries to "${pendingRemove?.url}" will stop immediately.`}
confirmLabel="Remove"
onConfirm={() => {
if (pendingRemove) onDelete(pendingRemove.id);
setPendingRemove(null);
}}
onCancel={() => setPendingRemove(null)}
/>
<h1 className="text-3xl font-semibold tracking-tight">Webhooks</h1>
<form onSubmit={onCreate} className="flex flex-col gap-3">
<label className="flex flex-col gap-1 text-sm">
Expand Down Expand Up @@ -85,9 +107,7 @@ export default function WebhooksPage() {
</div>
<button
type="button"
onClick={() =>
apiDelete(`/api/v1/webhooks/${w.id}`).then(() => load())
}
onClick={() => setPendingRemove(w)}
className="rounded border border-zinc-300 px-3 py-1 text-xs hover:border-rose-500 hover:text-rose-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 dark:border-zinc-700"
>
Remove
Expand Down
Loading