Merge pull request #32540 from BerriAI/litellm_/license-expiry-alert-b26348

feat(ui): add enterprise license expiry banner to admin dashboard
This commit is contained in:
yuneng-jiang 2026-07-08 15:27:21 -07:00 committed by GitHub
commit 5b8bf5357a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 343 additions and 30 deletions

View file

@ -0,0 +1,16 @@
import { useQuery, UseQueryResult } from "@tanstack/react-query";
import { getLicenseInfo, LicenseInfo } from "@/components/networking";
import { createQueryKeys } from "../common/queryKeysFactory";
const licenseInfoKeys = createQueryKeys("licenseInfo");
export const useLicenseInfo = (accessToken: string | null | undefined): UseQueryResult<LicenseInfo | null> => {
const options = {
queryKey: licenseInfoKeys.detail("license"),
queryFn: () => getLicenseInfo(accessToken!),
enabled: Boolean(accessToken),
staleTime: 5 * 60 * 1000,
retry: false,
};
return useQuery<LicenseInfo | null>(options);
};

View file

@ -21,6 +21,10 @@ vi.mock("@/components/DebugWarningBanner", () => ({
DebugWarningBanner: () => null,
}));
vi.mock("@/components/LicenseExpiryBanner", () => ({
LicenseExpiryBanner: () => null,
}));
vi.mock("@/contexts/ThemeContext", () => ({
ThemeProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));

View file

@ -8,6 +8,7 @@ import { useAuth } from "@/contexts/AuthContext";
import SidebarProvider from "@/app/(dashboard)/components/SidebarProvider";
import { useRouter, useSearchParams, usePathname } from "next/navigation";
import { DebugWarningBanner } from "@/components/DebugWarningBanner";
import { LicenseExpiryBanner } from "@/components/LicenseExpiryBanner";
import { MIGRATED_PAGES, migratedHref, legacyPageHref, legacyKeyForPathname } from "@/utils/migratedPages";
import { PluginModeProvider, usePluginMode } from "@/contexts/PluginModeContext";
import { createApiClient } from "@/lib/http/client";
@ -116,6 +117,7 @@ function DashboardShell({ children }: { children: React.ReactNode }) {
onToggleSidebar={() => setSidebarCollapsed((v) => !v)}
/>
<DebugWarningBanner accessToken={accessToken} />
<LicenseExpiryBanner accessToken={accessToken} />
<div className="flex flex-1">
{mode !== "ai-gateway" ? (
<div className="flex-1 flex">

View file

@ -0,0 +1,90 @@
import React from "react";
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { LicenseExpiryBannerView } from "./LicenseExpiryBanner";
import { LicenseInfo } from "./networking";
const daysFromNow = (n: number): string => {
const date = new Date();
date.setUTCDate(date.getUTCDate() + n);
return date.toISOString().slice(0, 10);
};
const licenseWith = (expiration_date: string | null): LicenseInfo => ({
has_license: expiration_date !== null,
license_type: expiration_date !== null ? "enterprise" : "community",
expiration_date,
allowed_features: [],
limits: { max_users: null, max_teams: null },
});
describe("LicenseExpiryBannerView", () => {
beforeEach(() => {
sessionStorage.clear();
});
afterEach(() => {
sessionStorage.clear();
});
it("renders nothing when there is no license info", () => {
const { container } = render(<LicenseExpiryBannerView licenseInfo={null} />);
expect(container).toBeEmptyDOMElement();
});
it("renders nothing when expiration_date is null (community or remote-validated)", () => {
const { container } = render(<LicenseExpiryBannerView licenseInfo={licenseWith(null)} />);
expect(container).toBeEmptyDOMElement();
});
it("renders nothing when expiry is more than 30 days out", () => {
const { container } = render(<LicenseExpiryBannerView licenseInfo={licenseWith(daysFromNow(40))} />);
expect(container).toBeEmptyDOMElement();
});
it("shows a dismissible amber warning within 30 days", () => {
const { container } = render(<LicenseExpiryBannerView licenseInfo={licenseWith(daysFromNow(20))} />);
expect(screen.getByText(/expires in 20 days/)).toBeInTheDocument();
expect(container.querySelector(".ant-alert-warning")).toBeInTheDocument();
expect(screen.queryByRole("button")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "sales@berri.ai" })).toHaveAttribute("href", "mailto:sales@berri.ai");
});
it("shows a non-dismissible red critical alert within 7 days", () => {
const { container } = render(<LicenseExpiryBannerView licenseInfo={licenseWith(daysFromNow(5))} />);
expect(screen.getByText(/expires in 5 days/)).toBeInTheDocument();
expect(container.querySelector(".ant-alert-error")).toBeInTheDocument();
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
it("says 'expires today' on the expiration day", () => {
render(<LicenseExpiryBannerView licenseInfo={licenseWith(daysFromNow(0))} />);
expect(screen.getByText(/expires today/)).toBeInTheDocument();
});
it("shows a non-dismissible red expired alert stating features are disabled", () => {
const { container } = render(<LicenseExpiryBannerView licenseInfo={licenseWith(daysFromNow(-3))} />);
expect(screen.getByText(/expired on/)).toBeInTheDocument();
expect(screen.getByText(/features are now disabled/i)).toBeInTheDocument();
expect(container.querySelector(".ant-alert-error")).toBeInTheDocument();
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
it("hides the warning after dismissal and stays hidden within the session", () => {
const expiration = daysFromNow(20);
const { unmount } = render(<LicenseExpiryBannerView licenseInfo={licenseWith(expiration)} />);
fireEvent.click(screen.getByRole("button"));
expect(screen.queryByText(/expires in 20 days/)).not.toBeInTheDocument();
unmount();
render(<LicenseExpiryBannerView licenseInfo={licenseWith(expiration)} />);
expect(screen.queryByText(/expires in 20 days/)).not.toBeInTheDocument();
});
it("still shows a critical alert even when its date was previously dismissed", () => {
const expiration = daysFromNow(5);
sessionStorage.setItem(`litellm:licenseExpiryBannerDismissed:${expiration}`, "true");
render(<LicenseExpiryBannerView licenseInfo={licenseWith(expiration)} />);
expect(screen.getByText(/expires in 5 days/)).toBeInTheDocument();
});
});

View file

@ -0,0 +1,95 @@
"use client";
import React, { useState } from "react";
import { Alert } from "antd";
import { LicenseInfo } from "@/components/networking";
import { useLicenseInfo } from "@/app/(dashboard)/hooks/license/useLicenseInfo";
import { formatExpiryDate, getDaysUntilExpiration, getLicenseExpiryTier } from "@/utils/licenseUtils";
const DISMISS_KEY_PREFIX = "litellm:licenseExpiryBannerDismissed:";
const SALES_EMAIL = "sales@berri.ai";
const salesLink = <a href={`mailto:${SALES_EMAIL}`}>{SALES_EMAIL}</a>;
interface LicenseExpiryBannerProps {
accessToken: string | null;
}
interface LicenseExpiryBannerViewProps {
licenseInfo: LicenseInfo | null;
}
const describeCountdown = (days: number): string => {
if (days <= 0) {
return "expires today";
}
if (days === 1) {
return "expires in 1 day";
}
return `expires in ${days} days`;
};
const expiryDescription = (tier: "warning" | "critical" | "expired"): React.ReactNode => {
if (tier === "expired") {
return <>Enterprise features are now disabled. Reach out to {salesLink} to restore access</>;
}
if (tier === "critical") {
return <>Renew now to avoid losing enterprise features. Reach out to {salesLink}</>;
}
return <>Renew before it lapses to keep enterprise features. Reach out to {salesLink}</>;
};
export const LicenseExpiryBannerView: React.FC<LicenseExpiryBannerViewProps> = ({ licenseInfo }) => {
const [locallyDismissed, setLocallyDismissed] = useState(false);
const expirationDate = licenseInfo?.expiration_date ?? null;
const tier = getLicenseExpiryTier(expirationDate);
const days = getDaysUntilExpiration(expirationDate);
if (expirationDate === null || tier === "none" || days === null) {
return null;
}
const isDismissible = tier === "warning";
const dismissKey = `${DISMISS_KEY_PREFIX}${expirationDate}`;
const previouslyDismissed =
isDismissible && typeof window !== "undefined" ? sessionStorage.getItem(dismissKey) === "true" : false;
if (isDismissible && (locallyDismissed || previouslyDismissed)) {
return null;
}
const formattedDate = formatExpiryDate(expirationDate);
const message =
tier === "expired"
? `Your LiteLLM Enterprise license expired on ${formattedDate}`
: `Your LiteLLM Enterprise license ${describeCountdown(days)} (${formattedDate})`;
const description = expiryDescription(tier);
const handleClose = () => {
if (typeof window !== "undefined") {
sessionStorage.setItem(dismissKey, "true");
}
setLocallyDismissed(true);
};
return (
<Alert
message={message}
description={description}
type={tier === "warning" ? "warning" : "error"}
showIcon
banner
closable={isDismissible}
onClose={handleClose}
style={{ marginBottom: 0, borderRadius: 0 }}
/>
);
};
export const LicenseExpiryBanner: React.FC<LicenseExpiryBannerProps> = ({ accessToken }) => {
const { data } = useLicenseInfo(accessToken);
return <LicenseExpiryBannerView licenseInfo={data ?? null} />;
};

View file

@ -2,6 +2,7 @@ import React from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import UsageIndicator from "./UsageIndicator";
vi.mock("./networking", () => ({
@ -17,6 +18,11 @@ import { getRemainingUsers } from "./networking";
const mockGetRemainingUsers = vi.mocked(getRemainingUsers);
const renderWithClient = (ui: React.ReactElement) => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
};
const DEFAULT_USAGE_DATA = {
total_users: 100,
total_users_used: 1,
@ -33,7 +39,7 @@ describe("UsageIndicator", () => {
});
it("should render when given access token and usage data loads", async () => {
render(<UsageIndicator accessToken="token" width={220} />);
renderWithClient(<UsageIndicator accessToken="token" width={220} />);
await screen.findByText("Usage");
@ -41,7 +47,7 @@ describe("UsageIndicator", () => {
});
it("should not show Near limit when users usage is below 80% (1/100 -> 1%)", async () => {
render(<UsageIndicator accessToken="token" width={220} />);
renderWithClient(<UsageIndicator accessToken="token" width={220} />);
await screen.findByText("Usage");
@ -58,7 +64,7 @@ describe("UsageIndicator", () => {
total_users_remaining: null,
});
render(<UsageIndicator accessToken="token" width={220} />);
renderWithClient(<UsageIndicator accessToken="token" width={220} />);
await waitFor(() => {
expect(screen.queryByText("Usage")).not.toBeInTheDocument();
@ -76,7 +82,7 @@ describe("UsageIndicator", () => {
total_teams_remaining: 1,
});
render(<UsageIndicator accessToken="token" width={220} />);
renderWithClient(<UsageIndicator accessToken="token" width={220} />);
await screen.findByText("Usage");
@ -94,7 +100,7 @@ describe("UsageIndicator", () => {
total_teams_remaining: null,
});
render(<UsageIndicator accessToken="token" width={220} />);
renderWithClient(<UsageIndicator accessToken="token" width={220} />);
await screen.findByText("Usage");
@ -112,7 +118,7 @@ describe("UsageIndicator", () => {
total_teams_remaining: -2,
});
render(<UsageIndicator accessToken="token" width={220} />);
renderWithClient(<UsageIndicator accessToken="token" width={220} />);
await screen.findByText("Usage");
@ -121,7 +127,7 @@ describe("UsageIndicator", () => {
});
it("should render nothing when accessToken is null", () => {
render(<UsageIndicator accessToken={null} width={220} />);
renderWithClient(<UsageIndicator accessToken={null} width={220} />);
expect(mockGetRemainingUsers).not.toHaveBeenCalled();
expect(screen.queryByText("Usage")).not.toBeInTheDocument();
@ -131,7 +137,7 @@ describe("UsageIndicator", () => {
const { useDisableUsageIndicator } = await import("@/app/(dashboard)/hooks/useDisableUsageIndicator");
(useDisableUsageIndicator as ReturnType<typeof vi.fn>).mockReturnValue(true);
render(<UsageIndicator accessToken="token" width={220} />);
renderWithClient(<UsageIndicator accessToken="token" width={220} />);
await waitFor(() => {
expect(screen.queryByText("Usage")).not.toBeInTheDocument();
@ -143,7 +149,7 @@ describe("UsageIndicator", () => {
it("should show Loading while fetching", () => {
mockGetRemainingUsers.mockImplementation(() => new Promise(() => {}));
render(<UsageIndicator accessToken="token" width={220} />);
renderWithClient(<UsageIndicator accessToken="token" width={220} />);
expect(screen.getByText("Loading...")).toBeInTheDocument();
});
@ -152,7 +158,7 @@ describe("UsageIndicator", () => {
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
mockGetRemainingUsers.mockRejectedValue(new Error("Network error"));
render(<UsageIndicator accessToken="token" width={220} />);
renderWithClient(<UsageIndicator accessToken="token" width={220} />);
expect(await screen.findByText("Failed to load usage data")).toBeInTheDocument();
@ -161,7 +167,7 @@ describe("UsageIndicator", () => {
it("should minimize when user clicks minimize button", async () => {
const user = userEvent.setup();
render(<UsageIndicator accessToken="token" width={220} />);
renderWithClient(<UsageIndicator accessToken="token" width={220} />);
await screen.findByText("Usage");
@ -174,7 +180,7 @@ describe("UsageIndicator", () => {
it("should restore from minimized when user clicks restore button", async () => {
const user = userEvent.setup();
render(<UsageIndicator accessToken="token" width={220} />);
renderWithClient(<UsageIndicator accessToken="token" width={220} />);
await screen.findByText("Usage");

View file

@ -12,9 +12,11 @@ import {
Users,
} from "lucide-react";
import { useEffect, useState } from "react";
import { getRemainingUsers, getLicenseInfo, LicenseInfo } from "./networking";
import { getRemainingUsers } from "./networking";
import { cn } from "@/lib/cva.config";
import { getDaysUntilExpiration } from "@/utils/licenseUtils";
import { useLicenseInfo } from "@/app/(dashboard)/hooks/license/useLicenseInfo";
interface UsageIndicatorProps {
accessToken: string | null;
@ -30,17 +32,6 @@ interface UsageData {
total_teams_remaining: number | null;
}
// Calculate days until expiration
const getDaysUntilExpiration = (expirationDate: string | null): number | null => {
if (!expirationDate) return null;
const expDate = new Date(expirationDate + "T00:00:00Z"); // Force UTC midnight
const now = new Date();
now.setHours(0, 0, 0, 0); // Normalize to local midnight
const diffTime = expDate.getTime() - now.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
return diffDays;
};
// Format expiration for display
const formatExpirationDisplay = (daysRemaining: number | null): string => {
if (daysRemaining === null) return "No expiration";
@ -58,10 +49,11 @@ export default function UsageIndicator({ accessToken, width = 220 }: UsageIndica
const [isExpanded, setIsExpanded] = useState(false);
const [isMinimized, setIsMinimized] = useState(false);
const [data, setData] = useState<UsageData | null>(null);
const [licenseInfo, setLicenseInfo] = useState<LicenseInfo | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const licenseInfo = useLicenseInfo(accessToken).data ?? null;
useEffect(() => {
const fetchData = async () => {
if (!accessToken) return;
@ -70,12 +62,8 @@ export default function UsageIndicator({ accessToken, width = 220 }: UsageIndica
setError(null);
try {
const [usageResult, licenseResult] = await Promise.all([
getRemainingUsers(accessToken),
getLicenseInfo(accessToken).catch(() => null), // Don't fail if license endpoint unavailable
]);
const usageResult = await getRemainingUsers(accessToken);
setData(usageResult);
setLicenseInfo(licenseResult);
} catch (err) {
console.error("Failed to fetch usage data:", err);
setError("Failed to load usage data");

View file

@ -0,0 +1,61 @@
import { describe, it, expect } from "vitest";
import { type LicenseExpiryTier, formatExpiryDate, getDaysUntilExpiration, getLicenseExpiryTier } from "./licenseUtils";
const NOW = new Date("2026-07-08T00:00:00Z");
describe("getDaysUntilExpiration", () => {
it("returns null for a null expiration", () => {
expect(getDaysUntilExpiration(null, NOW)).toBeNull();
});
it("returns null for an unparseable date", () => {
expect(getDaysUntilExpiration("not-a-date", NOW)).toBeNull();
});
it("returns 0 for an expiration on the current UTC day", () => {
expect(getDaysUntilExpiration("2026-07-08", NOW)).toBe(0);
});
it("returns a positive count for future dates", () => {
expect(getDaysUntilExpiration("2026-07-15", NOW)).toBe(7);
expect(getDaysUntilExpiration("2026-08-07", NOW)).toBe(30);
});
it("returns a negative count for a past date", () => {
expect(getDaysUntilExpiration("2026-07-07", NOW)).toBe(-1);
});
it("is timezone-independent within a UTC day", () => {
expect(getDaysUntilExpiration("2026-08-07", new Date("2026-07-08T00:00:01Z"))).toBe(30);
expect(getDaysUntilExpiration("2026-08-07", new Date("2026-07-08T23:59:59Z"))).toBe(30);
});
});
describe("getLicenseExpiryTier", () => {
const cases: Array<[string | null, LicenseExpiryTier]> = [
[null, "none"],
["not-a-date", "none"],
["2026-08-08", "none"],
["2026-08-07", "warning"],
["2026-07-16", "warning"],
["2026-07-15", "critical"],
["2026-07-09", "critical"],
["2026-07-08", "critical"],
["2026-07-07", "expired"],
["2026-01-01", "expired"],
];
it.each(cases)("classifies %s as %s", (date, expected) => {
expect(getLicenseExpiryTier(date, NOW)).toBe(expected);
});
});
describe("formatExpiryDate", () => {
it("formats an ISO date as a human-readable UTC date", () => {
expect(formatExpiryDate("2026-07-31")).toBe("Jul 31, 2026");
});
it("returns the input unchanged when unparseable", () => {
expect(formatExpiryDate("bogus")).toBe("bogus");
});
});

View file

@ -0,0 +1,51 @@
export type LicenseExpiryTier = "none" | "warning" | "critical" | "expired";
export const LICENSE_EXPIRY_WARNING_DAYS = 30;
export const LICENSE_EXPIRY_CRITICAL_DAYS = 7;
export const getDaysUntilExpiration = (expirationDate: string | null, now: Date = new Date()): number | null => {
if (!expirationDate) {
return null;
}
const expiration = new Date(`${expirationDate}T00:00:00Z`);
if (Number.isNaN(expiration.getTime())) {
return null;
}
const nowUtcMidnight = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
const diffMs = expiration.getTime() - nowUtcMidnight;
return Math.ceil(diffMs / (1000 * 60 * 60 * 24));
};
export const getLicenseExpiryTier = (expirationDate: string | null, now: Date = new Date()): LicenseExpiryTier => {
const days = getDaysUntilExpiration(expirationDate, now);
if (days === null) {
return "none";
}
if (days < 0) {
return "expired";
}
if (days <= LICENSE_EXPIRY_CRITICAL_DAYS) {
return "critical";
}
if (days <= LICENSE_EXPIRY_WARNING_DAYS) {
return "warning";
}
return "none";
};
const EXPIRY_DATE_FORMAT: Intl.DateTimeFormatOptions = {
year: "numeric",
month: "short",
day: "numeric",
timeZone: "UTC",
};
export const formatExpiryDate = (expirationDate: string): string => {
const date = new Date(`${expirationDate}T00:00:00Z`);
if (Number.isNaN(date.getTime())) {
return expirationDate;
}
return date.toLocaleDateString("en-US", EXPIRY_DATE_FORMAT);
};