diff --git a/README.md b/README.md index 07d2590..acc4c87 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ Backend endpoints are taken from the companion documentation page `src/app/docs/ | `/admin` | Admin control surface (pause/unpause/status) | `POST /api/v1/admin/pause`, `POST /api/v1/admin/unpause`, _(reads status via GET `/api/v1/admin/status` in code)_ | | `/agents` | Agents overview | _(reads agents list via `/api/v1/agents` in code)_ | | `/agents/:agent` | Single-agent view | _(reads agent details via `/api/v1/agents/:agent` in code)_ | -| `/api-keys` | API keys management | _(list/create/delete/update endpoints in code)_ | +| `/api-keys` | API keys management with created-at timestamps and a no-keys empty state | _(list/create/delete/update endpoints in code)_ | | `/changelog` | Changelog | _(static or calls `/api/v1/changelog` depending on implementation)_ | | `/docs` | Short API endpoint reference | `GET /api/v1/openapi.json` plus the prose list rendered from `sections` in `src/app/docs/page.tsx` (usage, settle, services, admin pause/unpause) | | `/events` | Event log renderer | _(reads events stream/poll via `/api/v1/events` endpoints in code)_ | @@ -117,6 +117,10 @@ Backend endpoints are taken from the companion documentation page `src/app/docs/ | `/usage` | Usage totals & settlement workflow | `POST /api/v1/usage`, `GET /api/v1/usage/:agent/:serviceId`, `POST /api/v1/settle` | | `/webhooks` | Webhooks management | _(calls webhooks endpoints in code)_ | +### API keys page notes + +The `/api-keys` page lists each key label, prefix, and created-at age with the absolute ISO timestamp available on hover. If the account has no keys, the page renders a clear "No API keys yet" empty state instead of an empty list while preserving the create, reveal-once, copy, and revoke confirmation flows. + ## Shared components See [docs/components.md](docs/components.md) for the shared component catalog, diff --git a/src/app/api-keys/page.test.tsx b/src/app/api-keys/page.test.tsx index 7cc5791..c6cc9e8 100644 --- a/src/app/api-keys/page.test.tsx +++ b/src/app/api-keys/page.test.tsx @@ -2,7 +2,14 @@ import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import ApiKeysPage from "./page"; const FAKE_KEY = "sk_live_abc123secretvalue"; -const mockItems = [{ prefix: "abc123", label: "my-key", createdAt: 1700000000 }]; +const BASE_TIME = new Date("2026-06-23T12:00:00.000Z"); +const mockItems = [ + { + prefix: "abc123", + label: "my-key", + createdAt: Math.floor((BASE_TIME.getTime() - 60_000) / 1_000), + }, +]; function mockFetchSuccess() { globalThis.fetch = jest.fn().mockResolvedValue({ @@ -12,7 +19,71 @@ function mockFetchSuccess() { } as unknown as Response); } -afterEach(() => jest.restoreAllMocks()); +beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(BASE_TIME); + Object.assign(navigator, { + clipboard: { writeText: jest.fn().mockResolvedValue(undefined) }, + }); +}); + +afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); +}); + +it("shows each key created-at as relative time with an absolute ISO title", async () => { + mockFetchSuccess(); + render(); + + await screen.findByText("my-key"); + + expect(screen.getByText("1m ago")).toBeInTheDocument(); + expect(screen.getByTitle("2026-06-23T11:59:00.000Z")).toBeInTheDocument(); +}); + +it("shows an announced empty state when there are no API keys", async () => { + globalThis.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ items: [] }), + } as unknown as Response); + + render(); + + expect(await screen.findByRole("status")).toHaveTextContent( + "No API keys yet", + ); + expect(screen.queryByRole("list")).not.toBeInTheDocument(); +}); + +it("shows a safe placeholder when a key is missing created-at", async () => { + globalThis.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + items: [{ prefix: "missing", label: "missing-created", createdAt: null }], + }), + } as unknown as Response); + + render(); + + await screen.findByText("missing-created"); + expect(screen.getByTitle("—")).toHaveTextContent("—"); +}); + +it("keeps load errors visible without showing an empty state", async () => { + globalThis.fetch = jest.fn().mockResolvedValue({ + ok: false, + status: 500, + text: async () => "server down", + } as unknown as Response); + + render(); + + expect(await screen.findByRole("alert")).toHaveTextContent("Request failed"); + expect(screen.queryByText("No API keys yet")).not.toBeInTheDocument(); +}); it("does not delete immediately when Revoke is clicked", async () => { mockFetchSuccess(); @@ -55,8 +126,16 @@ it("calls DELETE and closes dialog when confirmed", async () => { // 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); + .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 @@ -65,88 +144,134 @@ it("calls DELETE and closes dialog when confirmed", async () => { 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( + calls.some((c: string[]) => c[0].includes("/api/v1/api-keys/abc123")), + ).toBe(true); }); + await screen.findByText("No API keys yet"); expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); }); // --- reveal-once panel --- function mockFetchCreate() { - globalThis.fetch = jest.fn() - .mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ items: [] }) } as unknown as Response) - .mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ key: FAKE_KEY }) } as unknown as Response) - .mockResolvedValue({ ok: true, status: 200, json: async () => ({ items: [] }) } as unknown as Response); + globalThis.fetch = jest + .fn() + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ items: [] }), + } as unknown as Response) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ key: FAKE_KEY }), + } as unknown as Response) + .mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ items: [] }), + } as unknown as Response); } -beforeEach(() => { - Object.assign(navigator, { - clipboard: { writeText: jest.fn().mockResolvedValue(undefined) }, - }); -}); - it("shows the panel masked after key creation", async () => { mockFetchCreate(); render(); - fireEvent.change(screen.getByLabelText("Label"), { target: { value: "test" } }); - fireEvent.submit(screen.getByRole("button", { name: "Create" }).closest("form")!); - await waitFor(() => expect(screen.getByRole("status")).toBeInTheDocument()); - expect(screen.getByRole("status")).not.toHaveTextContent(FAKE_KEY); - expect(screen.getByRole("status")).toHaveTextContent("****"); + fireEvent.change(screen.getByLabelText("Label"), { + target: { value: "test" }, + }); + fireEvent.submit( + screen.getByRole("button", { name: "Create" }).closest("form")!, + ); + await waitFor(() => expect(screen.getByText(/New key/i)).toBeInTheDocument()); + const panel = screen.getByText(/New key/i).closest("div")!; + expect(panel).not.toHaveTextContent(FAKE_KEY); + expect(panel).toHaveTextContent("****"); }); it("reveals the full key when Reveal is clicked", async () => { mockFetchCreate(); render(); - fireEvent.change(screen.getByLabelText("Label"), { target: { value: "test" } }); - fireEvent.submit(screen.getByRole("button", { name: "Create" }).closest("form")!); + fireEvent.change(screen.getByLabelText("Label"), { + target: { value: "test" }, + }); + fireEvent.submit( + screen.getByRole("button", { name: "Create" }).closest("form")!, + ); await waitFor(() => screen.getByRole("button", { name: "Reveal" })); fireEvent.click(screen.getByRole("button", { name: "Reveal" })); - expect(screen.getByRole("status")).toHaveTextContent(FAKE_KEY); - expect(screen.getByRole("button", { name: "Hide" })).toHaveAttribute("aria-pressed", "true"); + expect(screen.getByText(/New key/i).closest("div")!).toHaveTextContent( + FAKE_KEY, + ); + expect(screen.getByRole("button", { name: "Hide" })).toHaveAttribute( + "aria-pressed", + "true", + ); }); it("hides the key again when Hide is clicked", async () => { mockFetchCreate(); render(); - fireEvent.change(screen.getByLabelText("Label"), { target: { value: "test" } }); - fireEvent.submit(screen.getByRole("button", { name: "Create" }).closest("form")!); + fireEvent.change(screen.getByLabelText("Label"), { + target: { value: "test" }, + }); + fireEvent.submit( + screen.getByRole("button", { name: "Create" }).closest("form")!, + ); await waitFor(() => screen.getByRole("button", { name: "Reveal" })); fireEvent.click(screen.getByRole("button", { name: "Reveal" })); fireEvent.click(screen.getByRole("button", { name: "Hide" })); - expect(screen.getByRole("status")).not.toHaveTextContent(FAKE_KEY); + expect(screen.getByText(/New key/i).closest("div")!).not.toHaveTextContent( + FAKE_KEY, + ); }); it("copies the full key to clipboard", async () => { mockFetchCreate(); render(); - fireEvent.change(screen.getByLabelText("Label"), { target: { value: "test" } }); - fireEvent.submit(screen.getByRole("button", { name: "Create" }).closest("form")!); + fireEvent.change(screen.getByLabelText("Label"), { + target: { value: "test" }, + }); + fireEvent.submit( + screen.getByRole("button", { name: "Create" }).closest("form")!, + ); await waitFor(() => screen.getByRole("button", { name: "Copy" })); fireEvent.click(screen.getByRole("button", { name: "Copy" })); await waitFor(() => - expect(navigator.clipboard.writeText).toHaveBeenCalledWith(FAKE_KEY) + expect(navigator.clipboard.writeText).toHaveBeenCalledWith(FAKE_KEY), ); }); it("removes the panel when Done is clicked", async () => { mockFetchCreate(); render(); - fireEvent.change(screen.getByLabelText("Label"), { target: { value: "test" } }); - fireEvent.submit(screen.getByRole("button", { name: "Create" }).closest("form")!); + fireEvent.change(screen.getByLabelText("Label"), { + target: { value: "test" }, + }); + fireEvent.submit( + screen.getByRole("button", { name: "Create" }).closest("form")!, + ); await waitFor(() => screen.getByRole("button", { name: /done/i })); fireEvent.click(screen.getByRole("button", { name: /done/i })); - expect(screen.queryByRole("status")).not.toBeInTheDocument(); + expect(screen.queryByText(/New key/i)).not.toBeInTheDocument(); }); it("handles clipboard unavailable without throwing", async () => { Object.assign(navigator, { - clipboard: { writeText: jest.fn().mockRejectedValue(new Error("no clipboard")) }, + clipboard: { + writeText: jest.fn().mockRejectedValue(new Error("no clipboard")), + }, }); mockFetchCreate(); render(); - fireEvent.change(screen.getByLabelText("Label"), { target: { value: "test" } }); - fireEvent.submit(screen.getByRole("button", { name: "Create" }).closest("form")!); + fireEvent.change(screen.getByLabelText("Label"), { + target: { value: "test" }, + }); + fireEvent.submit( + screen.getByRole("button", { name: "Create" }).closest("form")!, + ); await waitFor(() => screen.getByRole("button", { name: "Copy" })); - expect(() => fireEvent.click(screen.getByRole("button", { name: "Copy" }))).not.toThrow(); + expect(() => + fireEvent.click(screen.getByRole("button", { name: "Copy" })), + ).not.toThrow(); }); diff --git a/src/app/api-keys/page.tsx b/src/app/api-keys/page.tsx index ea79e1a..21f7077 100644 --- a/src/app/api-keys/page.tsx +++ b/src/app/api-keys/page.tsx @@ -4,8 +4,22 @@ import { useEffect, useState } from "react"; import { apiGet, apiPost, apiDelete } from "@/lib/apiClient"; import { ConfirmDialog } from "@/components/ConfirmDialog"; import { CopyButton } from "@/components/CopyButton"; +import { EmptyState } from "@/components/EmptyState"; +import { TimeAgo } from "@/components/TimeAgo"; +import { safeFormatTimestamp } from "@/lib/format"; -type KeyItem = { prefix: string; label: string; createdAt: number }; +type KeyItem = { + prefix: string; + label: string; + createdAt?: number | string | null; +}; + +function toTimestampMs(value: KeyItem["createdAt"]): number | null { + if (value === null || value === undefined) return null; + const numeric = typeof value === "number" ? value : Number(value); + if (!Number.isFinite(numeric)) return null; + return numeric < 1_000_000_000_000 ? numeric * 1_000 : numeric; +} export default function ApiKeysPage() { const [items, setItems] = useState(null as KeyItem[] | null); @@ -96,7 +110,10 @@ export default function ApiKeysPage() { {created && ( -
+

New key - copy now, shown only once.

@@ -123,25 +140,52 @@ export default function ApiKeysPage() {

)} - {items && ( + {items && items.length === 0 && ( +
+ +
+ )} + + {items && items.length > 0 && (
    - {items.map((k) => ( -
  • -
    -

    {k.label}

    -

    {k.prefix}...

    -
    - -
  • - ))} +
    +

    {k.label}

    +

    + {k.prefix}... +

    +

    + Created{" "} + {createdAtMs === null ? ( + + ) : ( + + )} +

    +
    + + + ); + })}
)} ); -} \ No newline at end of file +}