feat(ui): show invitation and reset password links in a copyable field (#44454)

* feat(ui): show invitation and reset password links in a copyable field

Put the link in a read-only input with a Copy button beside it, stack the
User ID and link labels above their values, and focus Copy on open so the
field shows the start of the URL. Copy now goes through the shared
copyToClipboard helper, which falls back to a selection copy where the
Clipboard API is unavailable.

* fix(ui): keep focus on the copy control after a fallback clipboard copy

The execCommand fallback focused a temporary textarea and removed it, so
focus fell to the page body and a second Enter on Copy did nothing.
Restore focus to the element that had it. Move the rendered dialog
tests to the integration tier.
This commit is contained in:
yuneng-jiang 2026-10-03 16:23:13 -07:00 • committed by GitHub
parent 5f969982fc
commit 427158eb5b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 176 additions and 36 deletions

View file

@ -0,0 +1,125 @@
import { afterEach, describe, it, expect, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import OnboardingModal, { InvitationLink } from "./onboarding_link";
const invitation: InvitationLink = {
id: "inv-123",
user_id: "user-abc",
is_accepted: false,
accepted_at: null,
expires_at: new Date("2030-01-01"),
created_at: new Date("2029-12-01"),
created_by: "admin",
updated_at: new Date("2029-12-01"),
updated_by: "admin",
has_user_setup_sso: false,
};
const renderModal = (modalType: "invitation" | "resetPassword", setVisible = vi.fn()) =>
render(
<OnboardingModal
isInvitationLinkModalVisible
setIsInvitationLinkModalVisible={setVisible}
baseUrl="http://localhost:4000/"
invitationLinkData={invitation}
modalType={modalType}
/>,
);
describe("OnboardingModal", () => {
it("shows the reset password link in a read-only field labelled for that flow", () => {
renderModal("resetPassword");
const field = screen.getByRole("textbox", { name: "Reset password link" });
expect(field).toHaveValue("http://localhost:4000/ui/onboarding?invitation_id=inv-123&action=reset_password");
expect(field).toHaveAttribute("readonly");
expect(screen.getByText("user-abc")).toBeInTheDocument();
});
it("shows the invitation link in a read-only field labelled for that flow", () => {
renderModal("invitation");
const field = screen.getByRole("textbox", { name: "Invitation link" });
expect(field).toHaveValue("http://localhost:4000/ui/onboarding?invitation_id=inv-123");
expect(field).toHaveAttribute("readonly");
});
it("focuses the copy button on open so the link field shows the start of the URL", async () => {
renderModal("resetPassword");
const copyButton = screen.getByRole("button", { name: "Copy password reset link" });
await waitFor(() => expect(copyButton).toHaveFocus());
});
it.each([
["invitation", "Copy invitation link", "http://localhost:4000/ui/onboarding?invitation_id=inv-123"],
[
"resetPassword",
"Copy password reset link",
"http://localhost:4000/ui/onboarding?invitation_id=inv-123&action=reset_password",
],
] as const)("copies exactly the displayed %s link when the copy button is pressed", async (modalType, label, url) => {
const user = userEvent.setup();
const writeText = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue();
renderModal(modalType);
await user.click(screen.getByRole("button", { name: label }));
expect(writeText).toHaveBeenCalledWith(url);
});
describe("without the Clipboard API, as on a plain-http deployment", () => {
const originalClipboard = Object.getOwnPropertyDescriptor(navigator, "clipboard");
afterEach(() => {
if (originalClipboard) Object.defineProperty(navigator, "clipboard", originalClipboard);
Reflect.deleteProperty(document, "execCommand");
vi.restoreAllMocks();
});
it("still copies exactly the displayed link through the selection fallback", () => {
Object.defineProperty(navigator, "clipboard", { value: undefined, configurable: true });
const selectedTexts: string[] = [];
vi.spyOn(HTMLTextAreaElement.prototype, "select").mockImplementation(function (this: HTMLTextAreaElement) {
selectedTexts.push(this.value);
});
const execCommand = vi.fn(() => true);
document.execCommand = execCommand;
renderModal("resetPassword");
fireEvent.click(screen.getByRole("button", { name: "Copy password reset link" }));
expect(selectedTexts).toEqual([
"http://localhost:4000/ui/onboarding?invitation_id=inv-123&action=reset_password",
]);
expect(execCommand).toHaveBeenCalledWith("copy");
});
it("keeps focus on the copy button after a fallback copy so Enter copies again", async () => {
const user = userEvent.setup();
Object.defineProperty(navigator, "clipboard", { value: undefined, configurable: true });
const execCommand = vi.fn(() => true);
document.execCommand = execCommand;
renderModal("resetPassword");
const copyButton = screen.getByRole("button", { name: "Copy password reset link" });
await waitFor(() => expect(copyButton).toHaveFocus());
await user.keyboard("{Enter}");
await user.keyboard("{Enter}");
expect(copyButton).toHaveFocus();
expect(execCommand).toHaveBeenCalledTimes(2);
});
});
it("asks the caller to hide the dialog when Escape is pressed", async () => {
const user = userEvent.setup();
const setVisible = vi.fn();
renderModal("invitation", setVisible);
await user.keyboard("{Escape}");
expect(setVisible).toHaveBeenCalledWith(false);
});
});

View file

@ -1,8 +1,9 @@
import React from "react";
import { Button } from "@/components/ui/button";
import { CopyToClipboard } from "react-copy-to-clipboard";
import { toast } from "@/lib/toast";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import React, { useId, useRef } from "react";
import { Copy } from "lucide-react";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group";
import { Label } from "@/components/ui/label";
import { copyToClipboard } from "@/utils/dataUtils";
export interface InvitationLink {
id: string;
@ -58,41 +59,53 @@ export default function OnboardingModal({
invitationLinkData,
modalType = "invitation",
}: OnboardingProps) {
const handleInvitationCancel = () => {
setIsInvitationLinkModalVisible(false);
};
const getInvitationUrl = () =>
buildOnboardingUrl({
baseUrl,
invitationId: invitationLinkData?.id,
hasUserSetupSso: invitationLinkData?.has_user_setup_sso ?? false,
resetPassword: modalType === "resetPassword",
});
const linkFieldId = useId();
const copyButtonRef = useRef<HTMLButtonElement>(null);
const isInvitation = modalType === "invitation";
const invitationUrl = buildOnboardingUrl({
baseUrl,
invitationId: invitationLinkData?.id,
hasUserSetupSso: invitationLinkData?.has_user_setup_sso ?? false,
resetPassword: !isInvitation,
});
return (
<Dialog open={isInvitationLinkModalVisible} onOpenChange={(open) => !open && handleInvitationCancel()}>
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[800px]">
<Dialog
open={isInvitationLinkModalVisible}
onOpenChange={(open) => !open && setIsInvitationLinkModalVisible(false)}
>
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-xl" initialFocus={copyButtonRef}>
<DialogHeader>
<DialogTitle>{modalType === "invitation" ? "Invitation Link" : "Reset Password Link"}</DialogTitle>
<DialogTitle>{isInvitation ? "Invitation Link" : "Reset Password Link"}</DialogTitle>
<DialogDescription>
{isInvitation
? "Copy and send the generated link to onboard this user to the proxy."
: "Copy and send the generated link to the user to reset their password."}
</DialogDescription>
</DialogHeader>
<p className="text-sm text-foreground">
{modalType === "invitation"
? "Copy and send the generated link to onboard this user to the proxy."
: "Copy and send the generated link to the user to reset their password."}
</p>
<div className="flex justify-between pt-5 pb-2">
<p className="text-base">User ID</p>
<p className="text-sm">{invitationLinkData?.user_id}</p>
</div>
<div className="flex justify-between pt-5 pb-2">
<p className="text-sm">{modalType === "invitation" ? "Invitation Link" : "Reset Password Link"}</p>
<p className="text-sm">{getInvitationUrl()}</p>
</div>
<div className="flex justify-end mt-5">
<CopyToClipboard text={getInvitationUrl()} onCopy={() => toast.success("Copied!")}>
<Button>{modalType === "invitation" ? "Copy invitation link" : "Copy password reset link"}</Button>
</CopyToClipboard>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<p className="text-xs text-muted-foreground">User ID</p>
<p className="font-mono text-sm break-all">{invitationLinkData?.user_id}</p>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor={linkFieldId} className="text-xs font-normal text-muted-foreground">
{isInvitation ? "Invitation link" : "Reset password link"}
</Label>
<InputGroup>
<InputGroupInput id={linkFieldId} readOnly value={invitationUrl} className="font-mono text-xs" />
<InputGroupAddon align="inline-end">
<InputGroupButton
ref={copyButtonRef}
aria-label={isInvitation ? "Copy invitation link" : "Copy password reset link"}
onClick={() => copyToClipboard(invitationUrl)}
>
<Copy />
Copy
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
</div>
</div>
</DialogContent>
</Dialog>

View file

@ -92,6 +92,7 @@ export const copyToClipboard = async (
// Fallback method using document.execCommand (deprecated but widely supported)
const fallbackCopyToClipboard = (text: string, messageText: string): boolean => {
try {
const previouslyFocused = document.activeElement;
const textArea = document.createElement("textarea");
textArea.value = text;
@ -107,6 +108,7 @@ const fallbackCopyToClipboard = (text: string, messageText: string): boolean =>
const successful = document.execCommand("copy");
document.body.removeChild(textArea);
if (previouslyFocused instanceof HTMLElement) previouslyFocused.focus();
if (successful) {
toast.success(messageText);