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);