From 48a244804dfed08116831571e8a0483be4031d70 Mon Sep 17 00:00:00 2001 From: Vincent Gao Date: Sun, 21 Jun 2026 21:03:49 +0200 Subject: [PATCH] feat: add api key secret copy control --- README.md | 1 + src/app/api-keys/page.test.tsx | 150 +++++++++++++++++++++++++++++++++ src/app/api-keys/page.tsx | 54 +++++++++++- 3 files changed, 204 insertions(+), 1 deletion(-) create mode 100644 src/app/api-keys/page.test.tsx diff --git a/README.md b/README.md index 3ab7b96..9b60f08 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/app/api-keys/page.test.tsx b/src/app/api-keys/page.test.tsx new file mode 100644 index 0000000..57d59d0 --- /dev/null +++ b/src/app/api-keys/page.test.tsx @@ -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( + + + + ); + +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((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(); + }); + }); +}); diff --git a/src/app/api-keys/page.tsx b/src/app/api-keys/page.tsx index dbb2724..3866370 100644 --- a/src/app/api-keys/page.tsx +++ b/src/app/api-keys/page.tsx @@ -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(null); const [label, setLabel] = useState(""); const [created, setCreated] = useState(null); const [error, setError] = useState(null); + const [copying, setCopying] = useState(false); const load = () => apiGet<{ items: Item[] }>("/api/v1/api-keys") @@ -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 (

Copy now — shown only once:

- {created} +
+ {created} + +
)} {error &&

{error}

}