diff --git a/ui/src/app/api-keys/page.tsx b/ui/src/app/api-keys/page.tsx index eb3c2955..fd384e95 100644 --- a/ui/src/app/api-keys/page.tsx +++ b/ui/src/app/api-keys/page.tsx @@ -2,6 +2,7 @@ import { Copy, Eye, EyeOff, Key, Plus, RefreshCw, Trash2 } from 'lucide-react'; import { useCallback, useEffect, useState } from 'react'; +import { toast } from 'sonner'; import { archiveApiKeyApiV1UserApiKeysApiKeyIdDelete, @@ -23,6 +24,7 @@ import { Skeleton } from '@/components/ui/skeleton'; import { useAppConfig } from '@/context/AppConfigContext'; import { useOrganizationTimezone } from '@/hooks/useOrganizationTimezone'; import { useAuth } from '@/lib/auth'; +import { copyTextToClipboard } from '@/lib/clipboard'; import { formatDateTime } from '@/lib/dateTime'; import logger from '@/lib/logger'; @@ -287,9 +289,11 @@ export default function APIKeysPage() { const copyToClipboard = async (text: string) => { try { - await navigator.clipboard.writeText(text); + await copyTextToClipboard(text); + toast.success('Key copied to clipboard'); } catch (err) { console.error('Failed to copy to clipboard:', err); + toast.error('Failed to copy key'); } }; diff --git a/ui/src/lib/clipboard.test.ts b/ui/src/lib/clipboard.test.ts new file mode 100644 index 00000000..08e5c9bc --- /dev/null +++ b/ui/src/lib/clipboard.test.ts @@ -0,0 +1,76 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { copyTextToClipboard } from "./clipboard"; + +const originalExecCommand = document.execCommand; + +describe("copyTextToClipboard", () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + + Object.defineProperty(document, "execCommand", { + configurable: true, + value: originalExecCommand, + }); + document.querySelectorAll("textarea").forEach((element) => element.remove()); + }); + + it("uses the Clipboard API when it is available", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + const execCommand = vi.fn(); + vi.stubGlobal("navigator", { clipboard: { writeText } }); + Object.defineProperty(document, "execCommand", { + configurable: true, + value: execCommand, + }); + + await copyTextToClipboard("secret"); + + expect(writeText).toHaveBeenCalledWith("secret"); + expect(execCommand).not.toHaveBeenCalled(); + }); + + it("uses the DOM fallback when the Clipboard API is unavailable", async () => { + const execCommand = vi.fn().mockReturnValue(true); + vi.stubGlobal("navigator", {}); + Object.defineProperty(document, "execCommand", { + configurable: true, + value: execCommand, + }); + + await copyTextToClipboard("secret"); + + expect(execCommand).toHaveBeenCalledWith("copy"); + expect(document.querySelector("textarea")).toBeNull(); + }); + + it("uses the DOM fallback when Clipboard API access is denied", async () => { + const writeText = vi.fn().mockRejectedValue(new Error("denied")); + const execCommand = vi.fn().mockReturnValue(true); + vi.stubGlobal("navigator", { clipboard: { writeText } }); + Object.defineProperty(document, "execCommand", { + configurable: true, + value: execCommand, + }); + + await copyTextToClipboard("secret"); + + expect(writeText).toHaveBeenCalledWith("secret"); + expect(execCommand).toHaveBeenCalledWith("copy"); + }); + + it("rejects and removes the temporary element when copying fails", async () => { + const execCommand = vi.fn().mockReturnValue(false); + vi.stubGlobal("navigator", {}); + Object.defineProperty(document, "execCommand", { + configurable: true, + value: execCommand, + }); + + await expect(copyTextToClipboard("secret")).rejects.toThrow( + "Copy command was unsuccessful", + ); + expect(document.querySelector("textarea")).toBeNull(); + }); +}); diff --git a/ui/src/lib/clipboard.ts b/ui/src/lib/clipboard.ts new file mode 100644 index 00000000..dae92fff --- /dev/null +++ b/ui/src/lib/clipboard.ts @@ -0,0 +1,33 @@ +export async function copyTextToClipboard(text: string): Promise { + if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(text); + return; + } catch { + // Fall back to the legacy copy command when Clipboard API access is denied. + } + } + + if (typeof document === "undefined" || typeof document.execCommand !== "function") { + throw new Error("Clipboard access is unavailable"); + } + + const textArea = document.createElement("textarea"); + textArea.value = text; + textArea.setAttribute("readonly", ""); + textArea.style.position = "fixed"; + textArea.style.opacity = "0"; + textArea.style.pointerEvents = "none"; + document.body.appendChild(textArea); + + try { + textArea.select(); + textArea.setSelectionRange(0, text.length); + + if (!document.execCommand("copy")) { + throw new Error("Copy command was unsuccessful"); + } + } finally { + textArea.remove(); + } +}