From c7a5e211c893e42b190e5065af41ec337788b3ba Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Fri, 19 Jun 2026 15:02:30 -0700 Subject: [PATCH] refactor(ui): point invitation links at the dedicated /onboarding route Invitation and reset-password links were built as /ui?invitation_id=..., which lands on the dashboard index and renders the onboarding form inline. They now point at the standalone /ui/onboarding route, so the index no longer has to special-case invitations. Old links keep working unchanged; the index still renders onboarding inline for ?invitation_id until the migration closeout removes that branch. Updates the three generators (the enterprise email builder, bulk user create, and the invitation/reset-password modal) and extracts the modal's URL building into a pure, unit-tested buildOnboardingUrl Refs LIT-3687 --- .../send_emails/base_email.py | 4 +- .../send_emails/test_base_email.py | 27 ++++++--- .../components/bulk_create_users_button.tsx | 2 +- .../src/components/onboarding_link.test.tsx | 59 +++++++++++++++++++ .../src/components/onboarding_link.tsx | 48 +++++++++------ 5 files changed, 112 insertions(+), 28 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/onboarding_link.test.tsx diff --git a/enterprise/litellm_enterprise/enterprise_callbacks/send_emails/base_email.py b/enterprise/litellm_enterprise/enterprise_callbacks/send_emails/base_email.py index 89c3b854686..3f776e38f47 100644 --- a/enterprise/litellm_enterprise/enterprise_callbacks/send_emails/base_email.py +++ b/enterprise/litellm_enterprise/enterprise_callbacks/send_emails/base_email.py @@ -912,9 +912,9 @@ class BaseEmailLogger(CustomLogger): """ Construct invitation link for the user - # http://localhost:4000/ui?invitation_id=7a096b3a-37c6-440f-9dd1-ba22e8043f6b + # http://localhost:4000/ui/onboarding?invitation_id=7a096b3a-37c6-440f-9dd1-ba22e8043f6b """ - return f"{base_url}/ui?invitation_id={invitation_id}" + return f"{base_url}/ui/onboarding?invitation_id={invitation_id}" async def send_email( self, diff --git a/tests/test_litellm/enterprise/enterprise_callbacks/send_emails/test_base_email.py b/tests/test_litellm/enterprise/enterprise_callbacks/send_emails/test_base_email.py index 5cabfe5fb7f..860e1036944 100644 --- a/tests/test_litellm/enterprise/enterprise_callbacks/send_emails/test_base_email.py +++ b/tests/test_litellm/enterprise/enterprise_callbacks/send_emails/test_base_email.py @@ -346,7 +346,9 @@ async def test_get_invitation_link(base_email_logger): result = await base_email_logger._get_invitation_link( user_id="test-user", base_url="http://test.com" ) - assert result == "http://test.com/ui?invitation_id=test-invitation-id" + assert ( + result == "http://test.com/ui/onboarding?invitation_id=test-invitation-id" + ) # Test with None user_id result = await base_email_logger._get_invitation_link( @@ -370,7 +372,7 @@ def test_construct_invitation_link(base_email_logger): result = base_email_logger._construct_invitation_link( invitation_id="test-id-123", base_url="http://test.com" ) - assert result == "http://test.com/ui?invitation_id=test-id-123" + assert result == "http://test.com/ui/onboarding?invitation_id=test-id-123" @pytest.mark.asyncio @@ -406,7 +408,10 @@ async def test_get_invitation_link_creates_new_when_none_exist(base_email_logger assert call_args["user_api_key_dict"].user_id == "test-user" # Verify the returned link uses the new invitation ID - assert result == "http://test.com/ui?invitation_id=new-invitation-id" + assert ( + result + == "http://test.com/ui/onboarding?invitation_id=new-invitation-id" + ) @pytest.mark.asyncio @@ -437,7 +442,10 @@ async def test_get_invitation_link_uses_existing_when_available(base_email_logge mock_create_invitation.assert_not_called() # Verify the returned link uses the existing invitation ID - assert result == "http://test.com/ui?invitation_id=existing-invitation-id" + assert ( + result + == "http://test.com/ui/onboarding?invitation_id=existing-invitation-id" + ) @pytest.mark.asyncio @@ -473,7 +481,10 @@ async def test_get_invitation_link_creates_new_when_list_is_none(base_email_logg assert call_args["user_api_key_dict"].user_id == "test-user" # Verify the returned link uses the new invitation ID - assert result == "http://test.com/ui?invitation_id=new-invitation-from-none" + assert ( + result + == "http://test.com/ui/onboarding?invitation_id=new-invitation-from-none" + ) @pytest.mark.asyncio @@ -493,7 +504,7 @@ async def test_get_email_params_user_invitation( with mock.patch.object( base_email_logger, "_get_invitation_link", - return_value="http://test.com/ui?invitation_id=test-id", + return_value="http://test.com/ui/onboarding?invitation_id=test-id", ): # Test with user invitation event result = await base_email_logger._get_email_params( @@ -507,7 +518,9 @@ async def test_get_email_params_user_invitation( == "https://litellm-listing.s3.amazonaws.com/litellm_logo.png" ) assert result.support_contact == "support@berri.ai" - assert result.base_url == "http://test.com/ui?invitation_id=test-id" + assert ( + result.base_url == "http://test.com/ui/onboarding?invitation_id=test-id" + ) assert result.recipient_email == "test@example.com" diff --git a/ui/litellm-dashboard/src/components/bulk_create_users_button.tsx b/ui/litellm-dashboard/src/components/bulk_create_users_button.tsx index 4c182bdfae4..687ed57683f 100644 --- a/ui/litellm-dashboard/src/components/bulk_create_users_button.tsx +++ b/ui/litellm-dashboard/src/components/bulk_create_users_button.tsx @@ -364,7 +364,7 @@ const BulkCreateUsersButton: React.FC = ({ if (!uiSettings?.SSO_ENABLED) { // Regular invitation flow const invitationData = await invitationCreateCall(accessToken, user_id); - const invitationUrl = new URL(`/ui?invitation_id=${invitationData.id}`, baseUrl).toString(); + const invitationUrl = new URL(`/ui/onboarding?invitation_id=${invitationData.id}`, baseUrl).toString(); setParsedData((current) => current.map((u, i) => diff --git a/ui/litellm-dashboard/src/components/onboarding_link.test.tsx b/ui/litellm-dashboard/src/components/onboarding_link.test.tsx new file mode 100644 index 00000000000..e99a6600d83 --- /dev/null +++ b/ui/litellm-dashboard/src/components/onboarding_link.test.tsx @@ -0,0 +1,59 @@ +import { describe, it, expect } from "vitest"; +import { buildOnboardingUrl } from "./onboarding_link"; + +describe("buildOnboardingUrl", () => { + it("points the invitation link at the dedicated /ui/onboarding route", () => { + expect( + buildOnboardingUrl({ + baseUrl: "http://localhost:4000/", + invitationId: "inv-123", + hasUserSetupSso: false, + resetPassword: false, + }), + ).toBe("http://localhost:4000/ui/onboarding?invitation_id=inv-123"); + }); + + it("preserves a server_root_path prefix before /ui/onboarding", () => { + expect( + buildOnboardingUrl({ + baseUrl: "https://proxy.example.com/litellm", + invitationId: "inv-123", + hasUserSetupSso: false, + resetPassword: false, + }), + ).toBe("https://proxy.example.com/litellm/ui/onboarding?invitation_id=inv-123"); + }); + + it("appends action=reset_password for the reset-password flow", () => { + expect( + buildOnboardingUrl({ + baseUrl: "http://localhost:4000/", + invitationId: "inv-123", + hasUserSetupSso: false, + resetPassword: true, + }), + ).toBe("http://localhost:4000/ui/onboarding?invitation_id=inv-123&action=reset_password"); + }); + + it("sends SSO users to the dashboard root, not the onboarding form", () => { + expect( + buildOnboardingUrl({ + baseUrl: "http://localhost:4000/", + invitationId: "inv-123", + hasUserSetupSso: true, + resetPassword: false, + }), + ).toBe("http://localhost:4000/ui"); + }); + + it("returns an empty string when no base URL is known yet", () => { + expect( + buildOnboardingUrl({ + baseUrl: "", + invitationId: "inv-123", + hasUserSetupSso: false, + resetPassword: false, + }), + ).toBe(""); + }); +}); diff --git a/ui/litellm-dashboard/src/components/onboarding_link.tsx b/ui/litellm-dashboard/src/components/onboarding_link.tsx index 7eb337a970a..da12eaf0e60 100644 --- a/ui/litellm-dashboard/src/components/onboarding_link.tsx +++ b/ui/litellm-dashboard/src/components/onboarding_link.tsx @@ -25,6 +25,29 @@ interface OnboardingProps { modalType?: "invitation" | "resetPassword"; } +export function buildOnboardingUrl({ + baseUrl, + invitationId, + hasUserSetupSso, + resetPassword, +}: { + baseUrl: string; + invitationId: string | undefined; + hasUserSetupSso: boolean; + resetPassword: boolean; +}): string { + if (!baseUrl) { + return ""; + } + const basePath = new URL(baseUrl).pathname; + const uiPath = basePath && basePath !== "/" ? `${basePath}/ui` : "ui"; + if (hasUserSetupSso) { + return new URL(uiPath, baseUrl).toString(); + } + const action = resetPassword ? "&action=reset_password" : ""; + return new URL(`${uiPath}/onboarding?invitation_id=${invitationId}${action}`, baseUrl).toString(); +} + export default function OnboardingModal({ isInvitationLinkModalVisible, setIsInvitationLinkModalVisible, @@ -41,24 +64,13 @@ export default function OnboardingModal({ setIsInvitationLinkModalVisible(false); }; - const getInvitationUrl = () => { - if (!baseUrl) { - return ""; - } - const baseUrlObj = new URL(baseUrl); - const basePath = baseUrlObj.pathname; // This will be "/litellm" or "" - const path = basePath && basePath !== "/" ? `${basePath}/ui` : "ui"; - // Get the path from the base URL - if (invitationLinkData?.has_user_setup_sso) { - return new URL(path, baseUrl).toString(); - } - let urlPath = `${path}?invitation_id=${invitationLinkData?.id}`; - if (modalType === "resetPassword") { - urlPath += "&action=reset_password"; - } - const url = new URL(urlPath, baseUrl).toString(); - return url; - }; + const getInvitationUrl = () => + buildOnboardingUrl({ + baseUrl, + invitationId: invitationLinkData?.id, + hasUserSetupSso: invitationLinkData?.has_user_setup_sso ?? false, + resetPassword: modalType === "resetPassword", + }); return (