fix: writeText exception on clipboard copy

This commit is contained in:
Abhishek Kumar 2026-07-25 14:59:58 +05:30
parent d236169f4a
commit 5139ae94a8
3 changed files with 114 additions and 1 deletions

View file

@ -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');
}
};

View file

@ -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();
});
});

33
ui/src/lib/clipboard.ts Normal file
View file

@ -0,0 +1,33 @@
export async function copyTextToClipboard(text: string): Promise<void> {
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();
}
}