diff --git a/ui/src/pages/CompanyInvites.test.tsx b/ui/src/pages/CompanyInvites.test.tsx index 27961dbc73c..280ffb11487 100644 --- a/ui/src/pages/CompanyInvites.test.tsx +++ b/ui/src/pages/CompanyInvites.test.tsx @@ -198,7 +198,11 @@ describe("CompanyInvites", () => { expect(clipboardWriteTextMock).toHaveBeenCalledWith("https://paperclip.local/invite/new-token"); expect(container.textContent).toContain("Latest invite link"); expect(container.textContent).toContain("This URL includes the current Paperclip domain returned by the server."); - expect(container.textContent).toContain("https://paperclip.local/invite/new-token"); + expect(container.querySelector('input[aria-label="Latest invite URL"]')).toHaveProperty( + "value", + "https://paperclip.local/invite/new-token", + ); + expect(container.textContent).toContain("Copy link"); expect(container.textContent).toContain("Open invite"); expect(pushToastMock).toHaveBeenCalledWith({ title: "Invite created", @@ -206,12 +210,12 @@ describe("CompanyInvites", () => { tone: "success", }); - const inviteFieldButton = Array.from(container.querySelectorAll("button")).find( - (button) => button.textContent?.includes("https://paperclip.local/invite/new-token"), + const copyLinkButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "Copy link", ); await act(async () => { - inviteFieldButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + copyLinkButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); @@ -230,6 +234,61 @@ describe("CompanyInvites", () => { }); }); + it("falls back to selectable text when browser clipboard access is unavailable", async () => { + Object.defineProperty(globalThis.navigator, "clipboard", { + configurable: true, + value: undefined, + }); + Object.defineProperty(document, "queryCommandSupported", { + configurable: true, + value: vi.fn((command: string) => command === "copy"), + }); + Object.defineProperty(document, "execCommand", { + configurable: true, + value: vi.fn(() => true), + }); + + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + await act(async () => { + root.render( + + + + + , + ); + }); + await flushReact(); + await flushReact(); + + const createButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "Create invite", + ); + + await act(async () => { + createButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + await flushReact(); + + const inviteInput = container.querySelector('input[aria-label="Latest invite URL"]') as HTMLInputElement | null; + expect(inviteInput?.value).toBe("https://paperclip.local/invite/new-token"); + expect(document.execCommand).toHaveBeenCalledWith("copy"); + expect(pushToastMock).toHaveBeenCalledWith({ + title: "Invite created", + body: "Invite ready below and copied to clipboard.", + tone: "success", + }); + + await act(async () => { + root.unmount(); + }); + }); + it("ignores legacy cached invite arrays and refetches paginated history", async () => { const root = createRoot(container); const queryClient = new QueryClient({ diff --git a/ui/src/pages/CompanyInvites.tsx b/ui/src/pages/CompanyInvites.tsx index f20ee636b3a..4a7a098cb2c 100644 --- a/ui/src/pages/CompanyInvites.tsx +++ b/ui/src/pages/CompanyInvites.tsx @@ -1,6 +1,6 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { Check, ExternalLink, MailPlus } from "lucide-react"; +import { Check, Copy, ExternalLink, MailPlus } from "lucide-react"; import { accessApi } from "@/api/access"; import { ApiError } from "@/api/client"; import { Button } from "@/components/ui/button"; @@ -52,6 +52,7 @@ export function CompanyInvites() { const [humanRole, setHumanRole] = useState<"owner" | "admin" | "operator" | "viewer">("operator"); const [latestInviteUrl, setLatestInviteUrl] = useState(null); const [latestInviteCopied, setLatestInviteCopied] = useState(false); + const latestInviteInputRef = useRef(null); useEffect(() => { if (!latestInviteCopied) return; @@ -61,6 +62,11 @@ export function CompanyInvites() { return () => window.clearTimeout(timeout); }, [latestInviteCopied]); + function selectLatestInviteUrl() { + latestInviteInputRef.current?.focus(); + latestInviteInputRef.current?.select(); + } + async function copyInviteUrl(url: string) { try { if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) { @@ -71,9 +77,36 @@ export function CompanyInvites() { // Fall through to the unavailable message below. } + const canUseLegacyCopy = + typeof document !== "undefined" && + typeof document.execCommand === "function" && + (typeof document.queryCommandSupported !== "function" || document.queryCommandSupported("copy")); + if (canUseLegacyCopy) { + const textarea = document.createElement("textarea"); + textarea.value = url; + textarea.setAttribute("readonly", "true"); + textarea.style.position = "fixed"; + textarea.style.top = "0"; + textarea.style.left = "-9999px"; + document.body.appendChild(textarea); + textarea.focus(); + textarea.select(); + textarea.setSelectionRange(0, textarea.value.length); + + try { + const copied = document.execCommand("copy"); + document.body.removeChild(textarea); + selectLatestInviteUrl(); + if (copied) return true; + } catch { + document.body.removeChild(textarea); + } + } + + selectLatestInviteUrl(); pushToast({ title: "Clipboard unavailable", - body: "Copy the invite URL manually from the field below.", + body: "The invite URL is selected. Copy it manually from the field.", tone: "warn", }); return false; @@ -251,17 +284,31 @@ export function CompanyInvites() { This URL includes the current Paperclip domain returned by the server. - +
+