From 1d9a86eac40fca902673ba57c613d6d9a6febe37 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Thu, 9 Jul 2026 11:59:22 -0700 Subject: [PATCH] refactor(ui): consolidate invitation flow into the dashboard layout (#32576) The App Router migration is complete: every page is a path route and the legacy `?page=` switch is gone from the index. This closes it out. The `/ui/` index (page.tsx) kept its own duplicate copy of teams state, a teams fetch, and keys/addKey plumbing solely to feed a second `UserDashboard` render for the `invitation_id` case. That was redundant: `ApiKeysDashboard` already renders `UserDashboard` sourcing its own data, so the index is thinned to just render ``. The login redirect, the legacy `?page=` deep-link redirect for old bookmarks, and the post-login return-URL handling stay on the index. The invitation entry point now resolves in one place. Modern invitation links already point at the dedicated `/onboarding` route; the dashboard layout now redirects legacy `/ui/?invitation_id=` links there too (via `migratedHref`, the same base-aware redirect the index uses for `?page=`), instead of re-rendering that route's page component inline. This removes an import of one route's `page.tsx` into another module, and lets the now-unreachable `if (invitation_id) return ` branch in the shared `user_dashboard.tsx` be deleted along with its dead `Onboarding` import and `searchParams` read. A layout test asserts the redirect and fails if it regresses. `legacyPageHref` and the sidebar's migrated-vs-legacy href fallback are left in place; they are still live for the parent-category nav nodes (agentic, tools, experimental, settings) that are not page routes. eslint-metrics.json is resynced: -2 no-explicit-any from the removed `any` casts, plus pre-existing drift the gate requires the snapshot to match. --- ui/litellm-dashboard/eslint-metrics.json | 2 +- .../src/app/(dashboard)/layout.test.tsx | 30 ++++++++- .../src/app/(dashboard)/layout.tsx | 13 +++- .../src/app/(dashboard)/page.tsx | 62 +++---------------- .../src/components/user_dashboard.tsx | 11 ---- 5 files changed, 47 insertions(+), 71 deletions(-) diff --git a/ui/litellm-dashboard/eslint-metrics.json b/ui/litellm-dashboard/eslint-metrics.json index 37bad071081..fcf60934f64 100644 --- a/ui/litellm-dashboard/eslint-metrics.json +++ b/ui/litellm-dashboard/eslint-metrics.json @@ -1,5 +1,5 @@ { - "@typescript-eslint/no-explicit-any": 1982, + "@typescript-eslint/no-explicit-any": 1980, "complexity": 128, "local/no-large-inline-object-arg": 519, "local/no-long-condition-chain": 233, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/layout.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/layout.test.tsx index 7573ddb5a0f..92a1d40b0e3 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/layout.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/layout.test.tsx @@ -3,9 +3,13 @@ import { render, screen, waitFor } from "@testing-library/react"; import { AuthProvider } from "@/contexts/AuthContext"; import Layout from "./layout"; +const { replaceMock } = vi.hoisted(() => ({ replaceMock: vi.fn() })); + +let searchParamsValue = new URLSearchParams(); + vi.mock("next/navigation", () => ({ - useRouter: vi.fn(() => ({ push: vi.fn(), replace: vi.fn() })), - useSearchParams: vi.fn(() => new URLSearchParams()), + useRouter: vi.fn(() => ({ push: vi.fn(), replace: replaceMock })), + useSearchParams: vi.fn(() => searchParamsValue), usePathname: vi.fn(() => "/ui/guardrails"), })); @@ -58,6 +62,7 @@ describe("(dashboard) Layout", () => { beforeEach(() => { vi.clearAllMocks(); pendingUiConfig = createDeferred(); + searchParamsValue = new URLSearchParams(); }); it("does not mount route content until getUiConfig has resolved", async () => { @@ -79,4 +84,25 @@ describe("(dashboard) Layout", () => { expect(screen.getByTestId("navbar")).toBeTruthy(); expect(screen.queryByTestId("loading-screen")).toBeNull(); }); + + it("redirects an invitation link to the onboarding route instead of rendering the dashboard shell", async () => { + searchParamsValue = new URLSearchParams("invitation_id=abc123"); + + render( + + +
+ + , + ); + + pendingUiConfig.resolve(); + + await waitFor(() => + expect(replaceMock).toHaveBeenCalledWith(expect.stringContaining("/onboarding?invitation_id=abc123")), + ); + expect(screen.queryByTestId("page-content")).toBeNull(); + expect(screen.queryByTestId("navbar")).toBeNull(); + expect(screen.queryByTestId("sidebar")).toBeNull(); + }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx b/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx index c84209b80cf..b8eb4e66ed0 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx @@ -137,17 +137,26 @@ function DashboardShell({ children }: { children: React.ReactNode }) { } function LayoutContent({ children }: { children: React.ReactNode }) { + const router = useRouter(); const searchParams = useSearchParams(); const { accessToken, authLoading } = useAuth(); const isInvitationFlow = Boolean(searchParams.get("invitation_id")); - if (authLoading) { + // Legacy invitation links point at /ui/?invitation_id=; the onboarding form now lives at its own + // /onboarding route. Redirect once ui-config has loaded so migratedHref resolves the SERVER_ROOT_PATH base. + useEffect(() => { + if (!authLoading && isInvitationFlow) { + router.replace(`${migratedHref("onboarding")}?${searchParams.toString()}`); + } + }, [authLoading, isInvitationFlow, router, searchParams]); + + if (authLoading || isInvitationFlow) { return ; } return ( - {isInvitationFlow ? children : {children}} + {children} ); } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/page.tsx index cb4a4a0de03..6c0d780183a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/page.tsx @@ -1,11 +1,8 @@ "use client"; import ApiKeysDashboard from "@/app/(dashboard)/api-keys/ApiKeysDashboard"; -import { teamListCall as v2TeamListCall } from "@/app/(dashboard)/hooks/teams/useTeams"; import LoadingScreen from "@/components/common_components/LoadingScreen"; -import { Team } from "@/components/key_team_helpers/key_list"; import { proxyBaseUrl } from "@/components/networking"; -import UserDashboard from "@/components/user_dashboard"; import { useAuth } from "@/contexts/AuthContext"; import { buildLoginUrlWithReturn, @@ -16,32 +13,20 @@ import { } from "@/utils/returnUrlUtils"; import { MIGRATED_PAGES, migratedHref } from "@/utils/migratedPages"; import { useRouter, useSearchParams } from "next/navigation"; -import { Suspense, useEffect, useRef, useState } from "react"; +import { Suspense, useEffect, useRef } from "react"; function CreateKeyPageContent() { - const { authLoading, token, userID, userRole, userEmail, accessToken, premiumUser, setUserRole, setUserEmail } = - useAuth(); - - const [teams, setTeams] = useState(null); - const [keys, setKeys] = useState([]); + const { authLoading, token } = useAuth(); const router = useRouter(); const searchParams = useSearchParams()!; - const [createClicked, setCreateClicked] = useState(false); - - const invitation_id = searchParams.get("invitation_id"); const explicitPage = searchParams.get("page"); - const page = explicitPage || "api-keys"; // Track if we've already attempted a return URL redirect to prevent race conditions const hasAttemptedReturnRedirectRef = useRef(false); - const addKey = (data: any) => { - setKeys((prevData) => (prevData ? [...prevData, data] : [data])); - setCreateClicked(() => !createClicked); - }; - const redirectToLogin = authLoading === false && token === null && invitation_id === null; + const redirectToLogin = authLoading === false && token === null; useEffect(() => { if (redirectToLogin) { @@ -55,15 +40,13 @@ function CreateKeyPageContent() { } }, [redirectToLogin]); - // Redirect legacy query-param pages to their new path-based routes. Only when the page is - // explicitly requested via ?page=, so the bare landing renders inline and the post-login - // return-URL handling below stays intact. + // Redirect legacy ?page= deep links (old bookmarks) to their path-based routes. const isLegacyRedirect = explicitPage !== null && explicitPage in MIGRATED_PAGES; useEffect(() => { if (!authLoading && isLegacyRedirect) { - router.replace(migratedHref(MIGRATED_PAGES[page])); + router.replace(migratedHref(MIGRATED_PAGES[explicitPage])); } - }, [authLoading, isLegacyRedirect, page, router]); + }, [authLoading, isLegacyRedirect, explicitPage, router]); // Check for a stored return URL after successful authentication // This handles the case where user comes back from SSO and we need to redirect to the original URL @@ -102,42 +85,11 @@ function CreateKeyPageContent() { } }, [token]); - useEffect(() => { - if (accessToken && userID && userRole) { - v2TeamListCall(accessToken, 1, 100, { - userID: userRole !== "Admin" && userRole !== "Admin Viewer" ? userID : null, - }) - .then((response) => setTeams(response.teams ?? [])) - .catch(console.error); - } - }, [accessToken, userID, userRole]); - if (authLoading || redirectToLogin || isLegacyRedirect) { return ; } - return ( - <> - {invitation_id ? ( - - ) : ( - - )} - - ); + return ; } export default function CreateKeyPage() { diff --git a/ui/litellm-dashboard/src/components/user_dashboard.tsx b/ui/litellm-dashboard/src/components/user_dashboard.tsx index acb9333b051..689b5680fcc 100644 --- a/ui/litellm-dashboard/src/components/user_dashboard.tsx +++ b/ui/litellm-dashboard/src/components/user_dashboard.tsx @@ -2,9 +2,7 @@ import { clearTokenCookies, getCookie } from "@/utils/cookieUtils"; import { Col, Grid } from "@tremor/react"; import { jwtDecode } from "jwt-decode"; -import { useSearchParams } from "next/navigation"; import React, { useEffect, useState } from "react"; -import Onboarding from "../app/onboarding/page"; import { fetchTeams } from "./common_components/fetch_teams"; import { KeyResponse, Team } from "./key_team_helpers/key_list"; import { @@ -76,13 +74,8 @@ const UserDashboard: React.FC = ({ const [userSpendData, setUserSpendData] = useState(null); const [currentOrg, setCurrentOrg] = useState(null); - // Assuming useSearchParams() hook exists and works in your setup - const searchParams = useSearchParams()!; - const token = getCookie("token"); - const invitation_id = searchParams.get("invitation_id"); - const [accessToken, setAccessToken] = useState(null); const [teamSpend, setTeamSpend] = useState(null); const [userModels, setUserModels] = useState([]); @@ -232,10 +225,6 @@ const UserDashboard: React.FC = ({ } }, [selectedTeam]); - if (invitation_id != null) { - return ; - } - function gotoLogin() { // Clear token cookies using the utility function clearTokenCookies();