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
This commit is contained in:
ryan-crabbe-berri 2026-06-19 15:02:30 -07:00
parent 4847fa5dd5
commit c7a5e211c8
5 changed files with 112 additions and 28 deletions

View file

@ -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,

View file

@ -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"

View file

@ -364,7 +364,7 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
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) =>

View file

@ -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("");
});
});

View file

@ -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 (
<Modal