From 427158eb5ba6e3a586253eb68e8790c16f5cd340 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Sat, 3 Oct 2026 16:23:13 -0700 Subject: [PATCH] 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. --- .../onboarding_link.integration.test.tsx | 125 ++++++++++++++++++ .../src/components/onboarding_link.tsx | 85 +++++++----- ui/litellm-dashboard/src/utils/dataUtils.ts | 2 + 3 files changed, 176 insertions(+), 36 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/onboarding_link.integration.test.tsx diff --git a/ui/litellm-dashboard/src/components/onboarding_link.integration.test.tsx b/ui/litellm-dashboard/src/components/onboarding_link.integration.test.tsx new file mode 100644 index 00000000000..523aebf0a5c --- /dev/null +++ b/ui/litellm-dashboard/src/components/onboarding_link.integration.test.tsx @@ -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( + , + ); + +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); + }); +}); diff --git a/ui/litellm-dashboard/src/components/onboarding_link.tsx b/ui/litellm-dashboard/src/components/onboarding_link.tsx index 441a85b1748..e0bb7e4da81 100644 --- a/ui/litellm-dashboard/src/components/onboarding_link.tsx +++ b/ui/litellm-dashboard/src/components/onboarding_link.tsx @@ -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(null); + const isInvitation = modalType === "invitation"; + const invitationUrl = buildOnboardingUrl({ + baseUrl, + invitationId: invitationLinkData?.id, + hasUserSetupSso: invitationLinkData?.has_user_setup_sso ?? false, + resetPassword: !isInvitation, + }); return ( - !open && handleInvitationCancel()}> - + !open && setIsInvitationLinkModalVisible(false)} + > + - {modalType === "invitation" ? "Invitation Link" : "Reset Password Link"} + {isInvitation ? "Invitation Link" : "Reset Password Link"} + + {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."} + -

- {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."} -

-
-

User ID

-

{invitationLinkData?.user_id}

-
-
-

{modalType === "invitation" ? "Invitation Link" : "Reset Password Link"}

-

{getInvitationUrl()}

-
-
- toast.success("Copied!")}> - - +
+
+

User ID

+

{invitationLinkData?.user_id}

+
+
+ + + + + copyToClipboard(invitationUrl)} + > + + Copy + + + +
diff --git a/ui/litellm-dashboard/src/utils/dataUtils.ts b/ui/litellm-dashboard/src/utils/dataUtils.ts index 8908041a626..c813a69b999 100644 --- a/ui/litellm-dashboard/src/utils/dataUtils.ts +++ b/ui/litellm-dashboard/src/utils/dataUtils.ts @@ -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);