mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-06 02:48:13 +00:00
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:
parent
5f969982fc
commit
427158eb5b
3 changed files with 176 additions and 36 deletions
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue