From 6d21a31e66dd8fe596af3c183e50271500ad6034 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Mon, 23 Mar 2026 23:30:33 -0700 Subject: [PATCH] Add un-closable license expiry banner to dashboard When enterprise license is within 14 days of expiry (or expired), show a persistent antd Alert banner at the top of all dashboard pages. Uses useAuthorized hook to avoid prop drilling. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/app/(dashboard)/layout.tsx | 2 + .../components/LicenseExpiryBanner.test.tsx | 162 ++++++++++++++++++ .../src/components/LicenseExpiryBanner.tsx | 64 +++++++ 3 files changed, 228 insertions(+) create mode 100644 ui/litellm-dashboard/src/components/LicenseExpiryBanner.test.tsx create mode 100644 ui/litellm-dashboard/src/components/LicenseExpiryBanner.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx b/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx index 94dd6eb3cf1..d892c4f932e 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx @@ -7,6 +7,7 @@ import SidebarProvider from "@/app/(dashboard)/components/SidebarProvider"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { useRouter, useSearchParams } from "next/navigation"; import { DebugWarningBanner } from "@/components/DebugWarningBanner"; +import { LicenseExpiryBanner } from "@/components/LicenseExpiryBanner"; /** ---- BASE URL HELPERS ---- */ function normalizeBasePrefix(raw: string | undefined | null): string { @@ -81,6 +82,7 @@ function LayoutContent({ children }: { children: React.ReactNode }) { toggleDarkMode={() => { }} /> +
({ + default: vi.fn(() => ({ accessToken: "test-token" })), +})); + +vi.mock("./networking", () => ({ + getLicenseInfo: vi.fn().mockResolvedValue(null), +})); + +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { getLicenseInfo } from "./networking"; + +const mockUseAuthorized = vi.mocked(useAuthorized); +const mockGetLicenseInfo = vi.mocked(getLicenseInfo); + +const FIXED_NOW = new Date("2026-03-15T12:00:00Z").getTime(); +let realDateNow: () => number; + +function makeLicense(expirationDate: string) { + return { + has_license: true, + license_type: "enterprise", + expiration_date: expirationDate, + allowed_features: [] as string[], + limits: { max_users: null, max_teams: null }, + }; +} + +describe("LicenseExpiryBanner", () => { + beforeEach(() => { + vi.clearAllMocks(); + realDateNow = Date.now; + Date.now = () => FIXED_NOW; + // Also override new Date() with no args + const OrigDate = Date; + const MockDate = class extends OrigDate { + constructor(...args: any[]) { + if (args.length === 0) { + super(FIXED_NOW); + } else { + // @ts-ignore + super(...args); + } + } + }; + // @ts-ignore + global.Date = MockDate; + // Preserve static methods + global.Date.now = () => FIXED_NOW; + global.Date.parse = OrigDate.parse; + global.Date.UTC = OrigDate.UTC; + + mockUseAuthorized.mockReturnValue({ + accessToken: "test-token", + isLoading: false, + isAuthorized: true, + token: "tok", + userId: "u1", + userEmail: "u@e.com", + userRole: "proxy_admin", + premiumUser: true, + disabledPersonalKeyCreation: null, + showSSOBanner: false, + } as any); + mockGetLicenseInfo.mockResolvedValue(null); + }); + + afterEach(() => { + Date.now = realDateNow; + }); + + it("should render nothing when there is no license info", async () => { + const { container } = render(); + await waitFor(() => { + expect(mockGetLicenseInfo).toHaveBeenCalledWith("test-token"); + }); + expect(container.innerHTML).toBe(""); + }); + + it("should render nothing when expiration is more than 14 days away", async () => { + mockGetLicenseInfo.mockResolvedValue(makeLicense("2026-04-15")); + + const { container } = render(); + await waitFor(() => { + expect(mockGetLicenseInfo).toHaveBeenCalled(); + }); + await waitFor(() => { + expect(container.querySelector(".ant-alert")).toBeNull(); + }); + }); + + it("should show a warning banner when license expires within 14 days", async () => { + // Now is 2026-03-15T12:00:00Z, exp is 2026-03-22T23:59:59 => ~7.5 days => ceil = 8 + mockGetLicenseInfo.mockResolvedValue(makeLicense("2026-03-22")); + + render(); + + expect( + await screen.findByText("Enterprise License Expiring in 8 days") + ).toBeInTheDocument(); + }); + + it("should show singular 'day' when 1 day remains", async () => { + // Now is 2026-03-15T12:00:00Z, exp is 2026-03-15T23:59:59 => ~0.5 days => ceil = 1 + mockGetLicenseInfo.mockResolvedValue(makeLicense("2026-03-15")); + + render(); + + expect( + await screen.findByText("Enterprise License Expiring in 1 day") + ).toBeInTheDocument(); + }); + + it("should show an error banner when the license has expired", async () => { + mockGetLicenseInfo.mockResolvedValue(makeLicense("2026-03-10")); + + render(); + + expect( + await screen.findByText("Enterprise License Expired") + ).toBeInTheDocument(); + }); + + it("should include renewal instructions in the description", async () => { + mockGetLicenseInfo.mockResolvedValue(makeLicense("2026-03-20")); + + render(); + + expect( + await screen.findByText(/Please contact support to renew before expiration/i) + ).toBeInTheDocument(); + }); + + it("should not render when accessToken is null", async () => { + mockUseAuthorized.mockReturnValue({ accessToken: null } as any); + + const { container } = render(); + await waitFor(() => { + expect(mockGetLicenseInfo).not.toHaveBeenCalled(); + }); + expect(container.innerHTML).toBe(""); + }); + + it("should not render when has_license is false", async () => { + mockGetLicenseInfo.mockResolvedValue({ + ...makeLicense("2026-03-20"), + has_license: false, + }); + + const { container } = render(); + await waitFor(() => { + expect(mockGetLicenseInfo).toHaveBeenCalled(); + }); + await waitFor(() => { + expect(container.querySelector(".ant-alert")).toBeNull(); + }); + }); +}); diff --git a/ui/litellm-dashboard/src/components/LicenseExpiryBanner.tsx b/ui/litellm-dashboard/src/components/LicenseExpiryBanner.tsx new file mode 100644 index 00000000000..58a7bff32a6 --- /dev/null +++ b/ui/litellm-dashboard/src/components/LicenseExpiryBanner.tsx @@ -0,0 +1,64 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { Alert } from "antd"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { getLicenseInfo, LicenseInfo } from "./networking"; + +const EXPIRY_WARNING_DAYS = 14; + +export const LicenseExpiryBanner: React.FC = () => { + const { accessToken } = useAuthorized(); + const [licenseInfo, setLicenseInfo] = useState(null); + + useEffect(() => { + if (!accessToken) return; + + getLicenseInfo(accessToken) + .then(setLicenseInfo) + .catch(() => null); + }, [accessToken]); + + if (!licenseInfo?.has_license || !licenseInfo.expiration_date) { + return null; + } + + const expDate = new Date(licenseInfo.expiration_date + "T23:59:59"); + const now = new Date(); + const diffMs = expDate.getTime() - now.getTime(); + const daysRemaining = Math.ceil(diffMs / (1000 * 60 * 60 * 24)); + + if (daysRemaining > EXPIRY_WARNING_DAYS) { + return null; + } + + const expirationDisplay = expDate.toLocaleString(undefined, { + year: "numeric", + month: "long", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + timeZoneName: "short", + }); + + const isExpired = daysRemaining <= 0; + const message = isExpired + ? "Enterprise License Expired" + : `Enterprise License Expiring in ${daysRemaining} day${daysRemaining === 1 ? "" : "s"}`; + + const description = isExpired + ? `Your enterprise license expired on ${expirationDisplay}. Please contact support to renew.` + : `Your enterprise license will expire on ${expirationDisplay}. Please contact support to renew before expiration.`; + + return ( + + ); +};