2 커밋 9b1a4bf8da ... 724d7272b0

작성자 SHA1 메시지 날짜
  Code_Uwe 724d7272b0 RHL-043 fix(admin-users): use clipboard helper for password copy 1 개월 전
  Code_Uwe a73576484c RHL-043 feat(ui): add clipboard helper with execCommand fallback 1 개월 전
3개의 변경된 파일154개의 추가작업 그리고 14개의 파일을 삭제
  1. 9 14
      components/admin/users/UserTemporaryPasswordField.jsx
  2. 56 0
      lib/frontend/ui/clipboard.js
  3. 89 0
      lib/frontend/ui/clipboard.test.js

+ 9 - 14
components/admin/users/UserTemporaryPasswordField.jsx

@@ -32,6 +32,7 @@ import {
 	notifyError,
 	notifyApiError,
 } from "@/lib/frontend/ui/toast";
+import { writeTextToClipboard } from "@/lib/frontend/ui/clipboard";
 
 function useCopySuccessTimeout(isActive, onReset) {
 	React.useEffect(() => {
@@ -160,23 +161,17 @@ export default function UserTemporaryPasswordField({
 
 	const handleCopyPassword = React.useCallback(async () => {
 		if (!hasTempPassword || isDisabled) return;
-		if (!navigator?.clipboard?.writeText) {
-			notifyError({
-				title: "Kopieren nicht verfügbar",
-				description: "Die Zwischenablage ist in diesem Browser nicht verfügbar.",
-			});
-			return;
-		}
 
-		try {
-			await navigator.clipboard.writeText(temporaryPassword);
+		const result = await writeTextToClipboard(temporaryPassword);
+		if (result.ok) {
 			setCopySuccess(true);
-		} catch {
-			notifyError({
-				title: "Passwort konnte nicht kopiert werden.",
-				description: "Bitte erneut versuchen.",
-			});
+			return;
 		}
+
+		notifyError({
+			title: "Kopieren nicht verfügbar",
+			description: "Die Zwischenablage ist in diesem Browser nicht verfügbar.",
+		});
 	}, [hasTempPassword, isDisabled, temporaryPassword]);
 
 	const displayValue = getDisplayedTemporaryPassword({

+ 56 - 0
lib/frontend/ui/clipboard.js

@@ -0,0 +1,56 @@
+function canUseClipboardApi() {
+	return (
+		typeof navigator !== "undefined" &&
+		Boolean(navigator.clipboard) &&
+		typeof navigator.clipboard.writeText === "function"
+	);
+}
+
+function copyWithExecCommand(text) {
+	if (typeof document === "undefined") return false;
+	if (typeof document.execCommand !== "function") return false;
+
+	const textarea = document.createElement("textarea");
+	textarea.value = String(text ?? "");
+	textarea.setAttribute("readonly", "");
+	textarea.style.position = "fixed";
+	textarea.style.top = "-9999px";
+	textarea.style.left = "-9999px";
+	textarea.style.opacity = "0";
+
+	document.body.appendChild(textarea);
+	textarea.focus();
+	textarea.select();
+	textarea.setSelectionRange(0, textarea.value.length);
+
+	let copied = false;
+	try {
+		copied = document.execCommand("copy");
+	} catch {
+		copied = false;
+	} finally {
+		textarea.remove();
+	}
+
+	return Boolean(copied);
+}
+
+export async function writeTextToClipboard(text) {
+	const value = String(text ?? "");
+
+	if (canUseClipboardApi()) {
+		try {
+			await navigator.clipboard.writeText(value);
+			return { ok: true, method: "clipboard" };
+		} catch {
+			// Fall back to execCommand below.
+		}
+	}
+
+	if (copyWithExecCommand(value)) {
+		return { ok: true, method: "execCommand" };
+	}
+
+	return { ok: false, method: null };
+}
+

+ 89 - 0
lib/frontend/ui/clipboard.test.js

@@ -0,0 +1,89 @@
+/* @vitest-environment node */
+
+import { describe, it, expect, vi, afterEach } from "vitest";
+import { writeTextToClipboard } from "./clipboard.js";
+
+function createDocumentStub({ copyResult = true } = {}) {
+	const textarea = {
+		value: "",
+		style: {},
+		setAttribute: vi.fn(),
+		focus: vi.fn(),
+		select: vi.fn(),
+		setSelectionRange: vi.fn(),
+		remove: vi.fn(),
+	};
+
+	const documentStub = {
+		createElement: vi.fn().mockReturnValue(textarea),
+		body: {
+			appendChild: vi.fn(),
+		},
+		execCommand: vi.fn().mockReturnValue(copyResult),
+	};
+
+	return { documentStub, textarea };
+}
+
+describe("lib/frontend/ui/clipboard", () => {
+	afterEach(() => {
+		vi.unstubAllGlobals();
+		vi.restoreAllMocks();
+	});
+
+	it("uses navigator.clipboard when available", async () => {
+		const writeText = vi.fn().mockResolvedValue(undefined);
+		vi.stubGlobal("navigator", {
+			clipboard: { writeText },
+		});
+
+		const { documentStub } = createDocumentStub({ copyResult: true });
+		vi.stubGlobal("document", documentStub);
+
+		const result = await writeTextToClipboard("secret");
+
+		expect(writeText).toHaveBeenCalledWith("secret");
+		expect(documentStub.execCommand).not.toHaveBeenCalled();
+		expect(result).toEqual({ ok: true, method: "clipboard" });
+	});
+
+	it("falls back to execCommand when Clipboard API is unavailable", async () => {
+		vi.stubGlobal("navigator", {});
+		const { documentStub, textarea } = createDocumentStub({ copyResult: true });
+		vi.stubGlobal("document", documentStub);
+
+		const result = await writeTextToClipboard("secret");
+
+		expect(documentStub.createElement).toHaveBeenCalledWith("textarea");
+		expect(documentStub.body.appendChild).toHaveBeenCalledWith(textarea);
+		expect(documentStub.execCommand).toHaveBeenCalledWith("copy");
+		expect(textarea.remove).toHaveBeenCalledTimes(1);
+		expect(result).toEqual({ ok: true, method: "execCommand" });
+	});
+
+	it("falls back to execCommand when Clipboard API throws", async () => {
+		const writeText = vi.fn().mockRejectedValue(new Error("not allowed"));
+		vi.stubGlobal("navigator", {
+			clipboard: { writeText },
+		});
+
+		const { documentStub } = createDocumentStub({ copyResult: true });
+		vi.stubGlobal("document", documentStub);
+
+		const result = await writeTextToClipboard("secret");
+
+		expect(writeText).toHaveBeenCalledWith("secret");
+		expect(documentStub.execCommand).toHaveBeenCalledWith("copy");
+		expect(result).toEqual({ ok: true, method: "execCommand" });
+	});
+
+	it("returns ok=false when no copy mechanism is available", async () => {
+		vi.stubGlobal("navigator", {});
+		vi.stubGlobal("document", undefined);
+
+		const result = await writeTextToClipboard("secret");
+
+		expect(result).toEqual({ ok: false, method: null });
+	});
+});
+