Skip to content
Closed
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ Next.js app for [StableRoute](https://github.com/your-org/stableroute) — Stell
- **Next.js 15** (App Router) with **React 19**
- **TailwindCSS** for styling
- Starter landing page; Stellar wallet integration can be added here
- API key management includes one-time secret display with copy-to-clipboard feedback

## Prerequisites

Expand Down
150 changes: 150 additions & 0 deletions src/app/api-keys/page.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import ApiKeysPage from "./page";
import { ToastProvider } from "@/components/ToastProvider";

const renderPage = () =>
render(
<ToastProvider>
<ApiKeysPage />
</ToastProvider>
);

const mockSuccessfulCreate = () => {
globalThis.fetch = jest
.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({ items: [] }),
} as unknown as Response)
.mockResolvedValueOnce({
ok: true,
json: async () => ({ key: "srk_secret_123" }),
} as unknown as Response)
.mockResolvedValueOnce({
ok: true,
json: async () => ({
items: [{ prefix: "srk_secr", label: "prod", createdAt: 1 }],
}),
} as unknown as Response);
};

const createKey = async () => {
renderPage();

await waitFor(() => {
expect(globalThis.fetch).toHaveBeenCalledWith(
"http://localhost:3001/api/v1/api-keys",
expect.any(Object)
);
});

fireEvent.change(screen.getByLabelText(/label/i), {
target: { value: "prod" },
});
fireEvent.click(screen.getByRole("button", { name: /create/i }));

await waitFor(() => {
expect(screen.getByText("srk_secret_123")).toBeInTheDocument();
});
};

describe("ApiKeysPage", () => {
const originalFetch = globalThis.fetch;
const originalClipboard = Object.getOwnPropertyDescriptor(navigator, "clipboard");
const originalExecCommand = document.execCommand;

afterEach(() => {
globalThis.fetch = originalFetch;
if (originalClipboard) {
Object.defineProperty(navigator, "clipboard", originalClipboard);
} else {
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: undefined,
});
}
document.execCommand = originalExecCommand;
jest.restoreAllMocks();
});

it("copies a newly created key with the Clipboard API and shows feedback", async () => {
mockSuccessfulCreate();
const writeText = jest.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText },
});

await createKey();
fireEvent.click(screen.getByRole("button", { name: /copy/i }));

await waitFor(() => {
expect(writeText).toHaveBeenCalledWith("srk_secret_123");
expect(screen.getByText(/api key copied/i)).toBeInTheDocument();
});
});

it("falls back to a temporary textarea when navigator.clipboard is unavailable", async () => {
mockSuccessfulCreate();
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: undefined,
});
document.execCommand = jest.fn().mockReturnValue(true);

await createKey();
fireEvent.click(screen.getByRole("button", { name: /copy/i }));

await waitFor(() => {
expect(document.execCommand).toHaveBeenCalledWith("copy");
expect(screen.getByText(/api key copied/i)).toBeInTheDocument();
});
expect(screen.queryByDisplayValue("srk_secret_123")).not.toBeInTheDocument();
});

it("shows an error toast when copy fails", async () => {
mockSuccessfulCreate();
const writeText = jest.fn().mockRejectedValue(new Error("denied"));
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText },
});

await createKey();
fireEvent.click(screen.getByRole("button", { name: /copy/i }));

await waitFor(() => {
expect(screen.getByRole("alert")).toHaveTextContent(/could not copy/i);
});
});

it("ignores rapid repeated clicks while a copy is pending", async () => {
mockSuccessfulCreate();
let resolveCopy: () => void = () => {};
const writeText = jest.fn(
() =>
new Promise<void>((resolve) => {
resolveCopy = resolve;
})
);
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText },
});

await createKey();
const button = screen.getByRole("button", { name: /copy/i });
fireEvent.click(button);

await waitFor(() => {
expect(screen.getByRole("button", { name: /copying/i })).toBeDisabled();
});
fireEvent.click(screen.getByRole("button", { name: /copying/i }));
expect(writeText).toHaveBeenCalledTimes(1);

resolveCopy();
await waitFor(() => {
expect(screen.getByText(/api key copied/i)).toBeInTheDocument();
});
});
});
54 changes: 53 additions & 1 deletion src/app/api-keys/page.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,43 @@
"use client";

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

type Item = { prefix: string; label: string; createdAt: number };

/**
* Copy sensitive text without logging or persisting it beyond the caller's state.
*/
async function copyTextToClipboard(text: string) {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return;
}

const input = document.createElement("textarea");
input.value = text;
input.setAttribute("readonly", "");
input.style.position = "fixed";
input.style.top = "-1000px";
document.body.appendChild(input);
input.select();
const copied = typeof document.execCommand === "function" && document.execCommand("copy");
document.body.removeChild(input);

if (!copied) {
throw new Error("Clipboard is not available");
}
}

export default function ApiKeysPage() {
const toast = useToast();
const [items, setItems] = useState<Item[] | null>(null);
const [label, setLabel] = useState("");
const [created, setCreated] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [copying, setCopying] = useState(false);

const load = () =>
apiGet<{ items: Item[] }>("/api/v1/api-keys")
Expand All @@ -32,6 +60,19 @@ export default function ApiKeysPage() {
}
};

const onCopyCreatedKey = async () => {
if (!created || copying) return;
setCopying(true);
try {
await copyTextToClipboard(created);
toast.push("API key copied");
} catch {
toast.push("Could not copy API key", "error");
} finally {
setCopying(false);
}
};

return (
<main
id="main-content"
Expand Down Expand Up @@ -59,7 +100,18 @@ export default function ApiKeysPage() {
{created && (
<div role="status" className="rounded border border-emerald-300 bg-emerald-50 p-3 text-sm dark:border-emerald-900 dark:bg-emerald-950">
<p className="font-medium">Copy now — shown only once:</p>
<code className="break-all">{created}</code>
<div className="mt-2 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<code className="break-all">{created}</code>
<Button
type="button"
variant="secondary"
onClick={onCopyCreatedKey}
disabled={copying}
className="self-start"
>
{copying ? "Copying…" : "Copy"}
</Button>
</div>
</div>
)}
{error && <p role="alert" className="text-sm text-rose-600">{error}</p>}
Expand Down