mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-07-25 12:01:04 +02:00
fix: writeText exception on clipboard copy
This commit is contained in:
parent
d236169f4a
commit
5139ae94a8
3 changed files with 114 additions and 1 deletions
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
76
ui/src/lib/clipboard.test.ts
Normal file
76
ui/src/lib/clipboard.test.ts
Normal 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
33
ui/src/lib/clipboard.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue