From f4303f5e22fcc82063f3541fcfdc55c590a20306 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Mon, 23 Feb 2026 18:01:10 -0800 Subject: [PATCH 01/13] feat: add useOnboarding hook with query and mutation Co-Authored-By: Claude Sonnet 4.6 --- .../hooks/onboarding/useOnboarding.test.ts | 126 ++++++++++++++++++ .../hooks/onboarding/useOnboarding.ts | 29 ++++ 2 files changed, 155 insertions(+) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/onboarding/useOnboarding.test.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/onboarding/useOnboarding.ts diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/onboarding/useOnboarding.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/onboarding/useOnboarding.test.ts new file mode 100644 index 00000000000..f5f63c0586e --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/onboarding/useOnboarding.test.ts @@ -0,0 +1,126 @@ +import { getOnboardingCredentials, claimOnboardingToken } from "@/components/networking"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderHook, waitFor, act } from "@testing-library/react"; +import React, { ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useOnboardingCredentials, useClaimOnboardingToken } from "./useOnboarding"; + +vi.mock("@/components/networking", () => ({ + getOnboardingCredentials: vi.fn(), + claimOnboardingToken: vi.fn(), +})); + +const mockUseUIConfig = vi.fn(); +vi.mock("@/app/(dashboard)/hooks/uiConfig/useUIConfig", () => ({ + useUIConfig: () => mockUseUIConfig(), +})); + +const mockCredentialsResponse = { token: "mock.jwt.token", login_url: "http://example.com/login" }; + +describe("useOnboardingCredentials", () => { + let queryClient: QueryClient; + + beforeEach(() => { + queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + vi.clearAllMocks(); + mockUseUIConfig.mockReturnValue({ isLoading: false }); + }); + + const wrapper = ({ children }: { children: ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children); + + it("fetches credentials when inviteId is provided and UIConfig is loaded", async () => { + (getOnboardingCredentials as any).mockResolvedValue(mockCredentialsResponse); + + const { result } = renderHook(() => useOnboardingCredentials("invite-123"), { wrapper }); + + expect(result.current.isLoading).toBe(true); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data).toEqual(mockCredentialsResponse); + expect(getOnboardingCredentials).toHaveBeenCalledWith("invite-123"); + expect(getOnboardingCredentials).toHaveBeenCalledTimes(1); + }); + + it("does not fetch when inviteId is null", async () => { + const { result } = renderHook(() => useOnboardingCredentials(null), { wrapper }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.isFetched).toBe(false); + expect(getOnboardingCredentials).not.toHaveBeenCalled(); + }); + + it("does not fetch while UIConfig is loading", async () => { + mockUseUIConfig.mockReturnValue({ isLoading: true }); + + const { result } = renderHook(() => useOnboardingCredentials("invite-123"), { wrapper }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.isFetched).toBe(false); + expect(getOnboardingCredentials).not.toHaveBeenCalled(); + }); + + it("exposes error state when fetch fails", async () => { + const error = new Error("Invalid invite"); + (getOnboardingCredentials as any).mockRejectedValue(error); + + const { result } = renderHook(() => useOnboardingCredentials("bad-invite"), { wrapper }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + + expect(result.current.error).toEqual(error); + }); +}); + +describe("useClaimOnboardingToken", () => { + let queryClient: QueryClient; + + beforeEach(() => { + queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + vi.clearAllMocks(); + mockUseUIConfig.mockReturnValue({ isLoading: false }); + }); + + const wrapper = ({ children }: { children: ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children); + + it("calls claimOnboardingToken with correct params", async () => { + (claimOnboardingToken as any).mockResolvedValue({ success: true }); + + const { result } = renderHook(() => useClaimOnboardingToken(), { wrapper }); + + act(() => { + result.current.mutate({ + accessToken: "acc-token", + inviteId: "invite-123", + userId: "user-456", + password: "secret", + }); + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(claimOnboardingToken).toHaveBeenCalledWith("acc-token", "invite-123", "user-456", "secret"); + }); + + it("exposes error state when mutation fails", async () => { + const error = new Error("Claim failed"); + (claimOnboardingToken as any).mockRejectedValue(error); + + const { result } = renderHook(() => useClaimOnboardingToken(), { wrapper }); + + act(() => { + result.current.mutate({ + accessToken: "acc-token", + inviteId: "invite-123", + userId: "user-456", + password: "secret", + }); + }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + + expect(result.current.error).toEqual(error); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/onboarding/useOnboarding.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/onboarding/useOnboarding.ts new file mode 100644 index 00000000000..554444f46c1 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/onboarding/useOnboarding.ts @@ -0,0 +1,29 @@ +import { claimOnboardingToken, getOnboardingCredentials } from "@/components/networking"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { useUIConfig } from "../uiConfig/useUIConfig"; +import { createQueryKeys } from "../common/queryKeysFactory"; + +const onboardingKeys = createQueryKeys("onboarding"); + +export const useOnboardingCredentials = (inviteId: string | null) => { + const { isLoading: isUIConfigLoading } = useUIConfig(); + return useQuery({ + queryKey: onboardingKeys.detail(inviteId ?? ""), + queryFn: async () => await getOnboardingCredentials(inviteId!), + enabled: Boolean(inviteId) && !isUIConfigLoading, + }); +}; + +export interface ClaimTokenParams { + accessToken: string; + inviteId: string; + userId: string; + password: string; +} + +export const useClaimOnboardingToken = () => { + return useMutation({ + mutationFn: async ({ accessToken, inviteId, userId, password }: ClaimTokenParams) => + await claimOnboardingToken(accessToken, inviteId, userId, password), + }); +}; From ba0ed492c40a486bf7cda9af5f7c52441a18a14e Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Mon, 23 Feb 2026 18:05:23 -0800 Subject: [PATCH 02/13] fix: improve type safety and defensive guard in useOnboarding hook Co-Authored-By: Claude Sonnet 4.6 --- .../hooks/onboarding/useOnboarding.test.ts | 14 ++++++++++++-- .../(dashboard)/hooks/onboarding/useOnboarding.ts | 12 ++++++++++-- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/onboarding/useOnboarding.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/onboarding/useOnboarding.test.ts index f5f63c0586e..50238b2a6f9 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/onboarding/useOnboarding.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/onboarding/useOnboarding.test.ts @@ -21,7 +21,12 @@ describe("useOnboardingCredentials", () => { let queryClient: QueryClient; beforeEach(() => { - queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); vi.clearAllMocks(); mockUseUIConfig.mockReturnValue({ isLoading: false }); }); @@ -77,7 +82,12 @@ describe("useClaimOnboardingToken", () => { let queryClient: QueryClient; beforeEach(() => { - queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); vi.clearAllMocks(); mockUseUIConfig.mockReturnValue({ isLoading: false }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/onboarding/useOnboarding.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/onboarding/useOnboarding.ts index 554444f46c1..0e3a4d236fd 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/onboarding/useOnboarding.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/onboarding/useOnboarding.ts @@ -5,11 +5,19 @@ import { createQueryKeys } from "../common/queryKeysFactory"; const onboardingKeys = createQueryKeys("onboarding"); +export interface OnboardingCredentials { + token: string; + login_url: string; +} + export const useOnboardingCredentials = (inviteId: string | null) => { const { isLoading: isUIConfigLoading } = useUIConfig(); - return useQuery({ + return useQuery({ queryKey: onboardingKeys.detail(inviteId ?? ""), - queryFn: async () => await getOnboardingCredentials(inviteId!), + queryFn: async () => { + if (!inviteId) throw new Error("inviteId is required"); + return getOnboardingCredentials(inviteId); + }, enabled: Boolean(inviteId) && !isUIConfigLoading, }); }; From 884904c6d1134ecf410b9ffa1087ef3fcf39a943 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Mon, 23 Feb 2026 18:08:31 -0800 Subject: [PATCH 03/13] feat: add OnboardingForm component with variant prop Co-Authored-By: Claude Sonnet 4.6 --- .../src/app/onboarding/OnboardingForm.tsx | 136 ++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 ui/litellm-dashboard/src/app/onboarding/OnboardingForm.tsx diff --git a/ui/litellm-dashboard/src/app/onboarding/OnboardingForm.tsx b/ui/litellm-dashboard/src/app/onboarding/OnboardingForm.tsx new file mode 100644 index 00000000000..406147c82ce --- /dev/null +++ b/ui/litellm-dashboard/src/app/onboarding/OnboardingForm.tsx @@ -0,0 +1,136 @@ +"use client"; +import React from "react"; +import { useSearchParams } from "next/navigation"; +import { jwtDecode } from "jwt-decode"; +import { Alert, Button, Card, Form, Input, Spin, Typography } from "antd"; +import { useOnboardingCredentials, useClaimOnboardingToken } from "@/app/(dashboard)/hooks/onboarding/useOnboarding"; +import { getProxyBaseUrl } from "@/components/networking"; + +type OnboardingFormProps = { + variant: "signup" | "reset_password"; +}; + +export function OnboardingForm({ variant }: OnboardingFormProps) { + const [form] = Form.useForm(); + const searchParams = useSearchParams()!; + const inviteId = searchParams.get("invitation_id"); + + const { + data: credentialsData, + isLoading: isCredentialsLoading, + isError: isCredentialsError, + } = useOnboardingCredentials(inviteId); + + const { mutate: claimToken, isPending } = useClaimOnboardingToken(); + + const decoded = credentialsData?.token + ? (jwtDecode(credentialsData.token) as { [key: string]: any }) + : null; + const userEmail = decoded?.user_email ?? ""; + const userId: string | null = decoded?.user_id ?? null; + const accessToken: string | null = decoded?.key ?? null; + const jwtToken: string | null = credentialsData?.token ?? null; + + const handleSubmit = (formValues: { password: string }) => { + if (!accessToken || !jwtToken || !userId || !inviteId) return; + + claimToken( + { accessToken, inviteId, userId, password: formValues.password }, + { + onSuccess: () => { + document.cookie = "token=" + jwtToken; + const proxyBaseUrl = getProxyBaseUrl(); + window.location.href = proxyBaseUrl + ? `${proxyBaseUrl}/ui/?login=success` + : "/ui/?login=success"; + }, + } + ); + }; + + if (isCredentialsLoading) { + return ( +
+ +
+ ); + } + + if (isCredentialsError) { + return ( +
+ +
+ ); + } + + return ( +
+ + + 🚅 LiteLLM + + + {variant === "reset_password" ? "Reset Password" : "Sign Up"} + + + {variant === "reset_password" + ? "Reset your password to access Admin UI." + : "Claim your user account to login to Admin UI."} + + + {variant === "signup" && ( + + SSO is under the Enterprise Tier. + +
+ } + showIcon + /> + )} + +
+ + + + + + + + +
+ +
+
+ + + ); +} From 2bc6dc5cad4c103a8caa93be4c9cf7410a50488e Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Mon, 23 Feb 2026 18:12:17 -0800 Subject: [PATCH 04/13] fix: sync email field value into AntD Form after credentials load --- ui/litellm-dashboard/src/app/onboarding/OnboardingForm.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ui/litellm-dashboard/src/app/onboarding/OnboardingForm.tsx b/ui/litellm-dashboard/src/app/onboarding/OnboardingForm.tsx index 406147c82ce..3adb30c601d 100644 --- a/ui/litellm-dashboard/src/app/onboarding/OnboardingForm.tsx +++ b/ui/litellm-dashboard/src/app/onboarding/OnboardingForm.tsx @@ -31,6 +31,10 @@ export function OnboardingForm({ variant }: OnboardingFormProps) { const accessToken: string | null = decoded?.key ?? null; const jwtToken: string | null = credentialsData?.token ?? null; + React.useEffect(() => { + if (userEmail) form.setFieldValue("user_email", userEmail); + }, [userEmail, form]); + const handleSubmit = (formValues: { password: string }) => { if (!accessToken || !jwtToken || !userId || !inviteId) return; @@ -108,7 +112,7 @@ export function OnboardingForm({ variant }: OnboardingFormProps) {
- + Date: Mon, 23 Feb 2026 18:16:38 -0800 Subject: [PATCH 05/13] fix: set cookie path and add error feedback on claim failure --- .../src/app/onboarding/OnboardingForm.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/ui/litellm-dashboard/src/app/onboarding/OnboardingForm.tsx b/ui/litellm-dashboard/src/app/onboarding/OnboardingForm.tsx index 3adb30c601d..6b229be06c1 100644 --- a/ui/litellm-dashboard/src/app/onboarding/OnboardingForm.tsx +++ b/ui/litellm-dashboard/src/app/onboarding/OnboardingForm.tsx @@ -14,6 +14,7 @@ export function OnboardingForm({ variant }: OnboardingFormProps) { const [form] = Form.useForm(); const searchParams = useSearchParams()!; const inviteId = searchParams.get("invitation_id"); + const [claimError, setClaimError] = React.useState(null); const { data: credentialsData, @@ -38,16 +39,21 @@ export function OnboardingForm({ variant }: OnboardingFormProps) { const handleSubmit = (formValues: { password: string }) => { if (!accessToken || !jwtToken || !userId || !inviteId) return; + setClaimError(null); + claimToken( { accessToken, inviteId, userId, password: formValues.password }, { onSuccess: () => { - document.cookie = "token=" + jwtToken; + document.cookie = `token=${jwtToken}; path=/; SameSite=Lax`; const proxyBaseUrl = getProxyBaseUrl(); window.location.href = proxyBaseUrl ? `${proxyBaseUrl}/ui/?login=success` : "/ui/?login=success"; }, + onError: (error: Error) => { + setClaimError(error.message || "Failed to submit. Please try again."); + }, } ); }; @@ -128,6 +134,10 @@ export function OnboardingForm({ variant }: OnboardingFormProps) { + {claimError && ( + + )} +
+ + + + )} + + + <> + + + + + + + + + +
+ {action === "reset_password" ? "Reset Password" : "Sign Up"} +
+ + +
+ ); +} + +export default function Onboarding() { + return ( + Loading...}> + + + ); +} From 10a3304e6a55d4f3b187668aa46127eb0de583c2 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Mon, 23 Feb 2026 20:18:39 -0800 Subject: [PATCH 07/13] refactor: simplify onboarding page to use OnboardingForm component --- .../src/app/onboarding/page.tsx | 145 ++---------------- 1 file changed, 9 insertions(+), 136 deletions(-) diff --git a/ui/litellm-dashboard/src/app/onboarding/page.tsx b/ui/litellm-dashboard/src/app/onboarding/page.tsx index 3bdf57907ee..d7840a7be3a 100644 --- a/ui/litellm-dashboard/src/app/onboarding/page.tsx +++ b/ui/litellm-dashboard/src/app/onboarding/page.tsx @@ -1,149 +1,22 @@ "use client"; -import React, { Suspense, useEffect, useState } from "react"; +import React, { Suspense } from "react"; import { useSearchParams } from "next/navigation"; -import { Card, Title, Text, TextInput, Callout, Button, Grid, Col } from "@tremor/react"; -import { RiCheckboxCircleLine } from "@remixicon/react"; -import { - getOnboardingCredentials, - claimOnboardingToken, - getUiConfig, - getProxyBaseUrl, -} from "@/components/networking"; -import { jwtDecode } from "jwt-decode"; -import { Form, Button as Button2 } from "antd"; -import { getCookie } from "@/utils/cookieUtils"; +import { OnboardingForm } from "./OnboardingForm"; function OnboardingContent() { - const [form] = Form.useForm(); const searchParams = useSearchParams()!; - const token = getCookie("token"); - const inviteID = searchParams.get("invitation_id"); const action = searchParams.get("action"); - const [accessToken, setAccessToken] = useState(null); - const [defaultUserEmail, setDefaultUserEmail] = useState(""); - const [userEmail, setUserEmail] = useState(""); - const [userID, setUserID] = useState(null); - const [loginUrl, setLoginUrl] = useState(""); - const [jwtToken, setJwtToken] = useState(""); - const [getUiConfigLoading, setGetUiConfigLoading] = useState(true); - - useEffect(() => { - getUiConfig().then((data) => { - // get the information for constructing the proxy base url, and then set the token and auth loading - console.log("ui config in onboarding.tsx:", data); - setGetUiConfigLoading(false); - }); - }, []); - - useEffect(() => { - if (!inviteID || getUiConfigLoading) { - // wait for the ui config to be loaded - return; - } - - getOnboardingCredentials(inviteID).then((data) => { - const login_url = data.login_url; - console.log("login_url:", login_url); - setLoginUrl(login_url); - - const token = data.token; - const decoded = jwtDecode(token) as { [key: string]: any }; - setJwtToken(token); - - console.log("decoded:", decoded); - setAccessToken(decoded.key); - - console.log("decoded user email:", decoded.user_email); - const user_email = decoded.user_email; - setUserEmail(user_email); - - const user_id = decoded.user_id; - setUserID(user_id); - }); - }, [inviteID, getUiConfigLoading]); - - const handleSubmit = (formValues: Record) => { - console.log("in handle submit. accessToken:", accessToken, "token:", jwtToken, "formValues:", formValues); - if (!accessToken || !jwtToken) { - return; - } - - formValues.user_email = userEmail; - - if (!userID || !inviteID) { - return; - } - claimOnboardingToken(accessToken, inviteID, userID, formValues.password).then((data) => { - // set cookie "token" to jwtToken - document.cookie = "token=" + jwtToken; - - const proxyBaseUrl = getProxyBaseUrl(); - console.log("proxyBaseUrl:", proxyBaseUrl); - - // Construct the full redirect URL using the proxyBaseUrl which includes the server root path - let redirectUrl = proxyBaseUrl ? `${proxyBaseUrl}/ui/?login=success` : "/ui/?login=success"; - console.log("redirecting to:", redirectUrl); - - window.location.href = redirectUrl; - }); - - // redirect to login page - }; - return ( -
- - 🚅 LiteLLM - {action === "reset_password" ? "Reset Password" : "Sign up"} - - {action === "reset_password" - ? "Reset your password to access Admin UI." - : "Claim your user account to login to Admin UI."} - - - {action !== "reset_password" && ( - - - SSO is under the Enterprise Tier. - - - - - - - )} - -
- <> - - - - - - - - - -
- {action === "reset_password" ? "Reset Password" : "Sign Up"} -
-
-
-
- ); + const variant = action === "reset_password" ? "reset_password" : "signup"; + return ; } export default function Onboarding() { return ( - Loading...}> + Loading... + } + > ); From a3d4a8752f2846639e7eeb9687506642cd80f473 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Mon, 23 Feb 2026 21:04:48 -0800 Subject: [PATCH 08/13] feat: extract OnboardingLoadingView component with tests Co-Authored-By: Claude Sonnet 4.6 --- .../onboarding/OnboardingLoadingView.test.tsx | 16 ++++++++++++++++ .../src/app/onboarding/OnboardingLoadingView.tsx | 11 +++++++++++ 2 files changed, 27 insertions(+) create mode 100644 ui/litellm-dashboard/src/app/onboarding/OnboardingLoadingView.test.tsx create mode 100644 ui/litellm-dashboard/src/app/onboarding/OnboardingLoadingView.tsx diff --git a/ui/litellm-dashboard/src/app/onboarding/OnboardingLoadingView.test.tsx b/ui/litellm-dashboard/src/app/onboarding/OnboardingLoadingView.test.tsx new file mode 100644 index 00000000000..b84c678b9dc --- /dev/null +++ b/ui/litellm-dashboard/src/app/onboarding/OnboardingLoadingView.test.tsx @@ -0,0 +1,16 @@ +import React from "react"; +import { render } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { OnboardingLoadingView } from "./OnboardingLoadingView"; + +describe("OnboardingLoadingView", () => { + it("renders a spinner container", () => { + const { container } = render(); + expect(container.firstChild).toBeInTheDocument(); + }); + + it("applies centering layout classes", () => { + const { container } = render(); + expect(container.firstChild).toHaveClass("flex", "justify-center"); + }); +}); diff --git a/ui/litellm-dashboard/src/app/onboarding/OnboardingLoadingView.tsx b/ui/litellm-dashboard/src/app/onboarding/OnboardingLoadingView.tsx new file mode 100644 index 00000000000..7efa1d2504f --- /dev/null +++ b/ui/litellm-dashboard/src/app/onboarding/OnboardingLoadingView.tsx @@ -0,0 +1,11 @@ +import React from "react"; +import { Spin } from "antd"; +import { LoadingOutlined } from "@ant-design/icons"; + +export function OnboardingLoadingView() { + return ( +
+ } size="large" /> +
+ ); +} From a347cf0a332ceb52ef579084bf0eae15999268e3 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Mon, 23 Feb 2026 21:06:17 -0800 Subject: [PATCH 09/13] feat: extract OnboardingErrorView component with tests Co-Authored-By: Claude Sonnet 4.6 --- .../onboarding/OnboardingErrorView.test.tsx | 25 +++++++++++++++++++ .../app/onboarding/OnboardingErrorView.tsx | 18 +++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 ui/litellm-dashboard/src/app/onboarding/OnboardingErrorView.test.tsx create mode 100644 ui/litellm-dashboard/src/app/onboarding/OnboardingErrorView.tsx diff --git a/ui/litellm-dashboard/src/app/onboarding/OnboardingErrorView.test.tsx b/ui/litellm-dashboard/src/app/onboarding/OnboardingErrorView.test.tsx new file mode 100644 index 00000000000..f2252a047a6 --- /dev/null +++ b/ui/litellm-dashboard/src/app/onboarding/OnboardingErrorView.test.tsx @@ -0,0 +1,25 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { OnboardingErrorView } from "./OnboardingErrorView"; + +describe("OnboardingErrorView", () => { + it("shows the failed to load invitation message", () => { + render(); + expect(screen.getByText("Failed to load invitation")).toBeInTheDocument(); + }); + + it("shows the expiry description", () => { + render(); + expect( + screen.getByText("The invitation link may be invalid or expired.") + ).toBeInTheDocument(); + }); + + it("renders a Back to Login link pointing to /ui/login", () => { + render(); + // antd Button with href renders as an element + const link = screen.getByRole("link", { name: "Back to Login" }); + expect(link).toHaveAttribute("href", "/ui/login"); + }); +}); diff --git a/ui/litellm-dashboard/src/app/onboarding/OnboardingErrorView.tsx b/ui/litellm-dashboard/src/app/onboarding/OnboardingErrorView.tsx new file mode 100644 index 00000000000..ca0f57c56ce --- /dev/null +++ b/ui/litellm-dashboard/src/app/onboarding/OnboardingErrorView.tsx @@ -0,0 +1,18 @@ +import React from "react"; +import { Alert, Button } from "antd"; + +export function OnboardingErrorView() { + return ( +
+ +
+ +
+
+ ); +} From 08734942703550f2903aa6a8efb7b738b2c78d73 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Mon, 23 Feb 2026 21:08:19 -0800 Subject: [PATCH 10/13] feat: extract OnboardingFormBody component with tests Co-Authored-By: Claude Sonnet 4.6 --- .../onboarding/OnboardingFormBody.test.tsx | 89 ++++++++++++++++++ .../src/app/onboarding/OnboardingFormBody.tsx | 93 +++++++++++++++++++ 2 files changed, 182 insertions(+) create mode 100644 ui/litellm-dashboard/src/app/onboarding/OnboardingFormBody.test.tsx create mode 100644 ui/litellm-dashboard/src/app/onboarding/OnboardingFormBody.tsx diff --git a/ui/litellm-dashboard/src/app/onboarding/OnboardingFormBody.test.tsx b/ui/litellm-dashboard/src/app/onboarding/OnboardingFormBody.test.tsx new file mode 100644 index 00000000000..40201aab3aa --- /dev/null +++ b/ui/litellm-dashboard/src/app/onboarding/OnboardingFormBody.test.tsx @@ -0,0 +1,89 @@ +import React from "react"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { OnboardingFormBody } from "./OnboardingFormBody"; + +const defaultProps = { + variant: "signup" as const, + userEmail: "test@example.com", + isPending: false, + claimError: null, + onSubmit: vi.fn(), +}; + +describe("OnboardingFormBody", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("shows 'Sign Up' heading for signup variant", () => { + render(); + expect(screen.getByRole("heading", { name: "Sign Up" })).toBeInTheDocument(); + }); + + it("shows 'Reset Password' heading for reset_password variant", () => { + render(); + expect(screen.getByRole("heading", { name: "Reset Password" })).toBeInTheDocument(); + }); + + it("shows SSO alert for signup variant", () => { + render(); + expect(screen.getByText("SSO")).toBeInTheDocument(); + }); + + it("hides SSO alert for reset_password variant", () => { + render(); + expect(screen.queryByText("SSO")).not.toBeInTheDocument(); + }); + + it("pre-fills the email field with userEmail", async () => { + render(); + await waitFor(() => { + expect(screen.getByLabelText("Email Address")).toHaveValue("user@example.com"); + }); + }); + + it("disables the email field", () => { + render(); + expect(screen.getByLabelText("Email Address")).toBeDisabled(); + }); + + it("shows claimError message when claimError is set", () => { + render(); + expect(screen.getByText("Something went wrong")).toBeInTheDocument(); + }); + + it("does not show claimError message when claimError is null", () => { + render(); + expect(screen.queryByText("Something went wrong")).not.toBeInTheDocument(); + }); + + it("shows a loading indicator on the submit button when isPending is true", () => { + render(); + // antd Button with loading={true} adds .ant-btn-loading class + expect(document.querySelector(".ant-btn-loading")).toBeInTheDocument(); + }); + + it("calls onSubmit with the typed password on form submit", async () => { + const user = userEvent.setup(); + const onSubmit = vi.fn(); + render(); + + await user.type(screen.getByLabelText("Password"), "mypassword"); + await user.click(screen.getByRole("button", { name: /sign up/i })); + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ password: "mypassword" }) + ); + }); + }); + + it("shows 'Reset Password' on the submit button for reset_password variant", () => { + render(); + expect( + screen.getByRole("button", { name: /reset password/i }) + ).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/onboarding/OnboardingFormBody.tsx b/ui/litellm-dashboard/src/app/onboarding/OnboardingFormBody.tsx new file mode 100644 index 00000000000..69abe4ffbf3 --- /dev/null +++ b/ui/litellm-dashboard/src/app/onboarding/OnboardingFormBody.tsx @@ -0,0 +1,93 @@ +import React from "react"; +import { Alert, Button, Card, Form, Input, Typography } from "antd"; + +type OnboardingFormBodyProps = { + variant: "signup" | "reset_password"; + userEmail: string; + isPending: boolean; + claimError: string | null; + onSubmit: (values: { password: string }) => void; +}; + +export function OnboardingFormBody({ + variant, + userEmail, + isPending, + claimError, + onSubmit, +}: OnboardingFormBodyProps) { + const [form] = Form.useForm(); + + React.useEffect(() => { + if (userEmail) form.setFieldValue("user_email", userEmail); + }, [userEmail, form]); + + return ( +
+ + + 🚅 LiteLLM + + + {variant === "reset_password" ? "Reset Password" : "Sign Up"} + + + {variant === "reset_password" + ? "Reset your password to access Admin UI." + : "Claim your user account to login to Admin UI."} + + + {variant === "signup" && ( + + SSO is under the Enterprise Tier. + +
+ } + showIcon + /> + )} + +
+ + + + + + + + + {claimError && ( + + )} + +
+ +
+ + + + ); +} From 02a53989cf43c384133b6380f1182a4238b9c20e Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Mon, 23 Feb 2026 21:20:22 -0800 Subject: [PATCH 11/13] fix: narrow onSubmit values and use semantic loading assertion --- .../src/app/onboarding/OnboardingFormBody.test.tsx | 4 ++-- .../src/app/onboarding/OnboardingFormBody.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/ui/litellm-dashboard/src/app/onboarding/OnboardingFormBody.test.tsx b/ui/litellm-dashboard/src/app/onboarding/OnboardingFormBody.test.tsx index 40201aab3aa..a5bfae3f880 100644 --- a/ui/litellm-dashboard/src/app/onboarding/OnboardingFormBody.test.tsx +++ b/ui/litellm-dashboard/src/app/onboarding/OnboardingFormBody.test.tsx @@ -61,8 +61,8 @@ describe("OnboardingFormBody", () => { it("shows a loading indicator on the submit button when isPending is true", () => { render(); - // antd Button with loading={true} adds .ant-btn-loading class - expect(document.querySelector(".ant-btn-loading")).toBeInTheDocument(); + // antd v5 renders a loading icon with aria-label="loading" inside the button + expect(screen.getByRole("img", { name: "loading" })).toBeInTheDocument(); }); it("calls onSubmit with the typed password on form submit", async () => { diff --git a/ui/litellm-dashboard/src/app/onboarding/OnboardingFormBody.tsx b/ui/litellm-dashboard/src/app/onboarding/OnboardingFormBody.tsx index 69abe4ffbf3..c57c7328b61 100644 --- a/ui/litellm-dashboard/src/app/onboarding/OnboardingFormBody.tsx +++ b/ui/litellm-dashboard/src/app/onboarding/OnboardingFormBody.tsx @@ -59,7 +59,7 @@ export function OnboardingFormBody({ /> )} -
+ onSubmit({ password: values.password })}> From a3491490f9bc3541af11e470ce5929d9cd1fbabb Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Mon, 23 Feb 2026 21:30:08 -0800 Subject: [PATCH 12/13] refactor onboarding --- .../src/app/onboarding/OnboardingForm.tsx | 106 +++--------------- 1 file changed, 13 insertions(+), 93 deletions(-) diff --git a/ui/litellm-dashboard/src/app/onboarding/OnboardingForm.tsx b/ui/litellm-dashboard/src/app/onboarding/OnboardingForm.tsx index 6b229be06c1..9a9d9d7e72c 100644 --- a/ui/litellm-dashboard/src/app/onboarding/OnboardingForm.tsx +++ b/ui/litellm-dashboard/src/app/onboarding/OnboardingForm.tsx @@ -2,16 +2,17 @@ import React from "react"; import { useSearchParams } from "next/navigation"; import { jwtDecode } from "jwt-decode"; -import { Alert, Button, Card, Form, Input, Spin, Typography } from "antd"; import { useOnboardingCredentials, useClaimOnboardingToken } from "@/app/(dashboard)/hooks/onboarding/useOnboarding"; import { getProxyBaseUrl } from "@/components/networking"; +import { OnboardingLoadingView } from "./OnboardingLoadingView"; +import { OnboardingErrorView } from "./OnboardingErrorView"; +import { OnboardingFormBody } from "./OnboardingFormBody"; type OnboardingFormProps = { variant: "signup" | "reset_password"; }; export function OnboardingForm({ variant }: OnboardingFormProps) { - const [form] = Form.useForm(); const searchParams = useSearchParams()!; const inviteId = searchParams.get("invitation_id"); const [claimError, setClaimError] = React.useState(null); @@ -27,15 +28,11 @@ export function OnboardingForm({ variant }: OnboardingFormProps) { const decoded = credentialsData?.token ? (jwtDecode(credentialsData.token) as { [key: string]: any }) : null; - const userEmail = decoded?.user_email ?? ""; + const userEmail: string = decoded?.user_email ?? ""; const userId: string | null = decoded?.user_id ?? null; const accessToken: string | null = decoded?.key ?? null; const jwtToken: string | null = credentialsData?.token ?? null; - React.useEffect(() => { - if (userEmail) form.setFieldValue("user_email", userEmail); - }, [userEmail, form]); - const handleSubmit = (formValues: { password: string }) => { if (!accessToken || !jwtToken || !userId || !inviteId) return; @@ -58,93 +55,16 @@ export function OnboardingForm({ variant }: OnboardingFormProps) { ); }; - if (isCredentialsLoading) { - return ( -
- -
- ); - } - - if (isCredentialsError) { - return ( -
- -
- ); - } + if (isCredentialsLoading) return ; + if (isCredentialsError) return ; return ( -
- - - 🚅 LiteLLM - - - {variant === "reset_password" ? "Reset Password" : "Sign Up"} - - - {variant === "reset_password" - ? "Reset your password to access Admin UI." - : "Claim your user account to login to Admin UI."} - - - {variant === "signup" && ( - - SSO is under the Enterprise Tier. - -
- } - showIcon - /> - )} - - - - - - - - - - - {claimError && ( - - )} - -
- -
- - - + ); } From 36f7722b0fce6fcc18b7a1bb72ad6e0c2a9bdb95 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Mon, 23 Feb 2026 21:53:42 -0800 Subject: [PATCH 13/13] fix: add QueryClientProvider, remove stale file, use should naming in tests - Wrap onboarding page with QueryClientProvider to prevent runtime crash (mirrors the same pattern used in LoginPage) - Stage deletion of stale litellm/ui/litellm-dashboard/src/app/onboarding/page.tsx committed at the wrong path - Rename all 16 test names to start with "should" per AGENTS.md convention --- .../src/app/onboarding/page.tsx | 150 ------------------ .../onboarding/OnboardingErrorView.test.tsx | 6 +- .../onboarding/OnboardingFormBody.test.tsx | 22 +-- .../onboarding/OnboardingLoadingView.test.tsx | 4 +- .../src/app/onboarding/page.tsx | 19 ++- 5 files changed, 28 insertions(+), 173 deletions(-) delete mode 100644 litellm/ui/litellm-dashboard/src/app/onboarding/page.tsx diff --git a/litellm/ui/litellm-dashboard/src/app/onboarding/page.tsx b/litellm/ui/litellm-dashboard/src/app/onboarding/page.tsx deleted file mode 100644 index 3bdf57907ee..00000000000 --- a/litellm/ui/litellm-dashboard/src/app/onboarding/page.tsx +++ /dev/null @@ -1,150 +0,0 @@ -"use client"; -import React, { Suspense, useEffect, useState } from "react"; -import { useSearchParams } from "next/navigation"; -import { Card, Title, Text, TextInput, Callout, Button, Grid, Col } from "@tremor/react"; -import { RiCheckboxCircleLine } from "@remixicon/react"; -import { - getOnboardingCredentials, - claimOnboardingToken, - getUiConfig, - getProxyBaseUrl, -} from "@/components/networking"; -import { jwtDecode } from "jwt-decode"; -import { Form, Button as Button2 } from "antd"; -import { getCookie } from "@/utils/cookieUtils"; - -function OnboardingContent() { - const [form] = Form.useForm(); - const searchParams = useSearchParams()!; - const token = getCookie("token"); - const inviteID = searchParams.get("invitation_id"); - const action = searchParams.get("action"); - const [accessToken, setAccessToken] = useState(null); - const [defaultUserEmail, setDefaultUserEmail] = useState(""); - const [userEmail, setUserEmail] = useState(""); - const [userID, setUserID] = useState(null); - const [loginUrl, setLoginUrl] = useState(""); - const [jwtToken, setJwtToken] = useState(""); - const [getUiConfigLoading, setGetUiConfigLoading] = useState(true); - - useEffect(() => { - getUiConfig().then((data) => { - // get the information for constructing the proxy base url, and then set the token and auth loading - console.log("ui config in onboarding.tsx:", data); - setGetUiConfigLoading(false); - }); - }, []); - - useEffect(() => { - if (!inviteID || getUiConfigLoading) { - // wait for the ui config to be loaded - return; - } - - getOnboardingCredentials(inviteID).then((data) => { - const login_url = data.login_url; - console.log("login_url:", login_url); - setLoginUrl(login_url); - - const token = data.token; - const decoded = jwtDecode(token) as { [key: string]: any }; - setJwtToken(token); - - console.log("decoded:", decoded); - setAccessToken(decoded.key); - - console.log("decoded user email:", decoded.user_email); - const user_email = decoded.user_email; - setUserEmail(user_email); - - const user_id = decoded.user_id; - setUserID(user_id); - }); - }, [inviteID, getUiConfigLoading]); - - const handleSubmit = (formValues: Record) => { - console.log("in handle submit. accessToken:", accessToken, "token:", jwtToken, "formValues:", formValues); - if (!accessToken || !jwtToken) { - return; - } - - formValues.user_email = userEmail; - - if (!userID || !inviteID) { - return; - } - claimOnboardingToken(accessToken, inviteID, userID, formValues.password).then((data) => { - // set cookie "token" to jwtToken - document.cookie = "token=" + jwtToken; - - const proxyBaseUrl = getProxyBaseUrl(); - console.log("proxyBaseUrl:", proxyBaseUrl); - - // Construct the full redirect URL using the proxyBaseUrl which includes the server root path - let redirectUrl = proxyBaseUrl ? `${proxyBaseUrl}/ui/?login=success` : "/ui/?login=success"; - console.log("redirecting to:", redirectUrl); - - window.location.href = redirectUrl; - }); - - // redirect to login page - }; - return ( -
- ); -} - -export default function Onboarding() { - return ( - Loading...}> - - - ); -} diff --git a/ui/litellm-dashboard/src/app/onboarding/OnboardingErrorView.test.tsx b/ui/litellm-dashboard/src/app/onboarding/OnboardingErrorView.test.tsx index f2252a047a6..d7a7ffb1b15 100644 --- a/ui/litellm-dashboard/src/app/onboarding/OnboardingErrorView.test.tsx +++ b/ui/litellm-dashboard/src/app/onboarding/OnboardingErrorView.test.tsx @@ -4,19 +4,19 @@ import { describe, it, expect } from "vitest"; import { OnboardingErrorView } from "./OnboardingErrorView"; describe("OnboardingErrorView", () => { - it("shows the failed to load invitation message", () => { + it("should show the failed to load invitation message", () => { render(); expect(screen.getByText("Failed to load invitation")).toBeInTheDocument(); }); - it("shows the expiry description", () => { + it("should show the expiry description", () => { render(); expect( screen.getByText("The invitation link may be invalid or expired.") ).toBeInTheDocument(); }); - it("renders a Back to Login link pointing to /ui/login", () => { + it("should render a Back to Login link pointing to /ui/login", () => { render(); // antd Button with href renders as an element const link = screen.getByRole("link", { name: "Back to Login" }); diff --git a/ui/litellm-dashboard/src/app/onboarding/OnboardingFormBody.test.tsx b/ui/litellm-dashboard/src/app/onboarding/OnboardingFormBody.test.tsx index a5bfae3f880..f742176d1ba 100644 --- a/ui/litellm-dashboard/src/app/onboarding/OnboardingFormBody.test.tsx +++ b/ui/litellm-dashboard/src/app/onboarding/OnboardingFormBody.test.tsx @@ -17,55 +17,55 @@ describe("OnboardingFormBody", () => { vi.clearAllMocks(); }); - it("shows 'Sign Up' heading for signup variant", () => { + it("should show 'Sign Up' heading for signup variant", () => { render(); expect(screen.getByRole("heading", { name: "Sign Up" })).toBeInTheDocument(); }); - it("shows 'Reset Password' heading for reset_password variant", () => { + it("should show 'Reset Password' heading for reset_password variant", () => { render(); expect(screen.getByRole("heading", { name: "Reset Password" })).toBeInTheDocument(); }); - it("shows SSO alert for signup variant", () => { + it("should show SSO alert for signup variant", () => { render(); expect(screen.getByText("SSO")).toBeInTheDocument(); }); - it("hides SSO alert for reset_password variant", () => { + it("should hide SSO alert for reset_password variant", () => { render(); expect(screen.queryByText("SSO")).not.toBeInTheDocument(); }); - it("pre-fills the email field with userEmail", async () => { + it("should pre-fill the email field with userEmail", async () => { render(); await waitFor(() => { expect(screen.getByLabelText("Email Address")).toHaveValue("user@example.com"); }); }); - it("disables the email field", () => { + it("should disable the email field", () => { render(); expect(screen.getByLabelText("Email Address")).toBeDisabled(); }); - it("shows claimError message when claimError is set", () => { + it("should show claimError message when claimError is set", () => { render(); expect(screen.getByText("Something went wrong")).toBeInTheDocument(); }); - it("does not show claimError message when claimError is null", () => { + it("should not show claimError message when claimError is null", () => { render(); expect(screen.queryByText("Something went wrong")).not.toBeInTheDocument(); }); - it("shows a loading indicator on the submit button when isPending is true", () => { + it("should show a loading indicator on the submit button when isPending is true", () => { render(); // antd v5 renders a loading icon with aria-label="loading" inside the button expect(screen.getByRole("img", { name: "loading" })).toBeInTheDocument(); }); - it("calls onSubmit with the typed password on form submit", async () => { + it("should call onSubmit with the typed password on form submit", async () => { const user = userEvent.setup(); const onSubmit = vi.fn(); render(); @@ -80,7 +80,7 @@ describe("OnboardingFormBody", () => { }); }); - it("shows 'Reset Password' on the submit button for reset_password variant", () => { + it("should show 'Reset Password' on the submit button for reset_password variant", () => { render(); expect( screen.getByRole("button", { name: /reset password/i }) diff --git a/ui/litellm-dashboard/src/app/onboarding/OnboardingLoadingView.test.tsx b/ui/litellm-dashboard/src/app/onboarding/OnboardingLoadingView.test.tsx index b84c678b9dc..21c5ccf69d0 100644 --- a/ui/litellm-dashboard/src/app/onboarding/OnboardingLoadingView.test.tsx +++ b/ui/litellm-dashboard/src/app/onboarding/OnboardingLoadingView.test.tsx @@ -4,12 +4,12 @@ import { describe, it, expect } from "vitest"; import { OnboardingLoadingView } from "./OnboardingLoadingView"; describe("OnboardingLoadingView", () => { - it("renders a spinner container", () => { + it("should render a spinner container", () => { const { container } = render(); expect(container.firstChild).toBeInTheDocument(); }); - it("applies centering layout classes", () => { + it("should apply centering layout classes", () => { const { container } = render(); expect(container.firstChild).toHaveClass("flex", "justify-center"); }); diff --git a/ui/litellm-dashboard/src/app/onboarding/page.tsx b/ui/litellm-dashboard/src/app/onboarding/page.tsx index d7840a7be3a..f424c9e6288 100644 --- a/ui/litellm-dashboard/src/app/onboarding/page.tsx +++ b/ui/litellm-dashboard/src/app/onboarding/page.tsx @@ -1,8 +1,11 @@ "use client"; import React, { Suspense } from "react"; import { useSearchParams } from "next/navigation"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { OnboardingForm } from "./OnboardingForm"; +const queryClient = new QueryClient(); + function OnboardingContent() { const searchParams = useSearchParams()!; const action = searchParams.get("action"); @@ -12,12 +15,14 @@ function OnboardingContent() { export default function Onboarding() { return ( - Loading... - } - > - - + + Loading... + } + > + + + ); }