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 && (
)}
);
-}
\ No newline at end of file
+}