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.
-
+
+