From d9976768db007825363908b62bc834d91d20a10d Mon Sep 17 00:00:00 2001 From: Matt Vollmer Date: Fri, 15 May 2026 16:52:55 -0400 Subject: [PATCH] fix(site): copy token value from modal (#25399) --- .../components/CodeExample/CodeExample.tsx | 4 +- site/src/components/CopyButton/CopyButton.tsx | 2 +- site/src/hooks/useClipboard.ts | 11 ++- .../CreateTokenPage/CreateTokenPage.test.tsx | 77 ++++++++++++++++++- 4 files changed, 87 insertions(+), 7 deletions(-) diff --git a/site/src/components/CodeExample/CodeExample.tsx b/site/src/components/CodeExample/CodeExample.tsx index c3aea0ac78..e146e12bd6 100644 --- a/site/src/components/CodeExample/CodeExample.tsx +++ b/site/src/components/CodeExample/CodeExample.tsx @@ -86,17 +86,17 @@ export const CodeExample: FC = ({ )} -
+
{showRevealButton && redactPattern && !secret && ( {showButtonLabel} diff --git a/site/src/components/CopyButton/CopyButton.tsx b/site/src/components/CopyButton/CopyButton.tsx index c4967b5b7f..763f8d1b6d 100644 --- a/site/src/components/CopyButton/CopyButton.tsx +++ b/site/src/components/CopyButton/CopyButton.tsx @@ -29,11 +29,11 @@ export const CopyButton: FC = ({ {label} diff --git a/site/src/hooks/useClipboard.ts b/site/src/hooks/useClipboard.ts index 2ce4750cc7..11c2518ba8 100644 --- a/site/src/hooks/useClipboard.ts +++ b/site/src/hooks/useClipboard.ts @@ -3,6 +3,7 @@ import { toast } from "sonner"; const CLIPBOARD_TIMEOUT_MS = 1_000; export const COPY_FAILED_MESSAGE = "Failed to copy text to clipboard"; +const DIALOG_SELECTOR = 'dialog[open], [role="dialog"], [role="alertdialog"]'; export const HTTP_FALLBACK_DATA_ID = "http-fallback"; export type UseClipboardInput = Readonly<{ @@ -99,6 +100,14 @@ export const useClipboard = ( function simulateClipboardWrite(textToCopy: string): boolean { const previousFocusTarget = document.activeElement; const dummyInput = document.createElement("input"); + // Keep the dummy input inside an open dialog so focus traps allow + // execCommand("copy") to select it. + const activeDialog = + previousFocusTarget instanceof HTMLElement + ? previousFocusTarget.closest(DIALOG_SELECTOR) + : undefined; + const dummyInputContainer = + activeDialog ?? document.querySelector(DIALOG_SELECTOR) ?? document.body; // Have to add test ID to dummy element for mocking purposes in tests dummyInput.setAttribute("data-testid", HTTP_FALLBACK_DATA_ID); @@ -119,7 +128,7 @@ function simulateClipboardWrite(textToCopy: string): boolean { style.padding = "0"; style.border = "0"; - document.body.appendChild(dummyInput); + dummyInputContainer.appendChild(dummyInput); dummyInput.value = textToCopy; dummyInput.focus(); dummyInput.select(); diff --git a/site/src/pages/CreateTokenPage/CreateTokenPage.test.tsx b/site/src/pages/CreateTokenPage/CreateTokenPage.test.tsx index 45005864a1..d1a2daca04 100644 --- a/site/src/pages/CreateTokenPage/CreateTokenPage.test.tsx +++ b/site/src/pages/CreateTokenPage/CreateTokenPage.test.tsx @@ -1,19 +1,31 @@ -import { screen, within } from "@testing-library/react"; +import { screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { API } from "#/api/api"; +import { HTTP_FALLBACK_DATA_ID } from "#/hooks/useClipboard"; import { renderWithAuth, waitForLoaderToBeRemoved, } from "#/testHelpers/renderHelpers"; import CreateTokenPage from "./CreateTokenPage"; +import { NANO_HOUR } from "./utils"; describe("TokenPage", () => { - it("shows the success modal", async () => { + const originalExecCommand = document.execCommand; + const originalNavigator = navigator; + + afterEach(() => { + document.execCommand = originalExecCommand; + vi.restoreAllMocks(); + }); + + const createToken = async () => { + vi.spyOn(API, "getTokenConfig").mockResolvedValue({ + max_token_lifetime: 90 * 24 * NANO_HOUR, + }); vi.spyOn(API, "createToken").mockResolvedValueOnce({ key: "abcd", }); - // When const { container } = renderWithAuth(, { route: "/settings/tokens/new", path: "/settings/tokens/new", @@ -25,8 +37,67 @@ describe("TokenPage", () => { await userEvent.click( within(form).getByRole("button", { name: /create token/i }), ); + }; + + it("shows the success modal", async () => { + // When + await createToken(); // Then expect(screen.getByText("abcd")).toBeInTheDocument(); }); + + it("selects only the created token from the success modal", async () => { + await createToken(); + + const tokenContainer = screen.getByText("abcd").closest("div"); + expect(tokenContainer).toBeInTheDocument(); + + const selectedRange = document.createRange(); + selectedRange.selectNodeContents(tokenContainer as HTMLElement); + + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(selectedRange); + + expect(selection?.toString().trim()).toBe("abcd"); + selection?.removeAllRanges(); + }); + + it("copies the created token from the success modal when clipboard fallback is used", async () => { + const mockClipboard: Clipboard = { + ...originalNavigator.clipboard, + writeText: vi.fn().mockRejectedValue(new Error("Clipboard unavailable")), + }; + vi.spyOn(window, "navigator", "get").mockImplementation(() => ({ + ...originalNavigator, + clipboard: mockClipboard, + })); + + let copiedText = ""; + document.execCommand = vi.fn((commandId) => { + const dummyInput = document.querySelector( + `input[data-testid=${HTTP_FALLBACK_DATA_ID}]`, + ); + const inputCanReceiveDialogFocus = + commandId === "copy" && + dummyInput instanceof HTMLInputElement && + dummyInput.closest('[role="dialog"]') !== null && + document.activeElement === dummyInput; + + if (!inputCanReceiveDialogFocus) { + return false; + } + + copiedText = dummyInput.value; + return true; + }); + + await createToken(); + await userEvent.click(screen.getByRole("button", { name: "Copy code" })); + + await waitFor(() => { + expect(copiedText).toBe("abcd"); + }); + }); });