mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-10 03:28:53 +00:00
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) <noreply@anthropic.com>
This commit is contained in:
parent
0ce1ea37c5
commit
6d21a31e66
3 changed files with 228 additions and 0 deletions
|
|
@ -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={() => { }}
|
||||
/>
|
||||
<DebugWarningBanner />
|
||||
<LicenseExpiryBanner />
|
||||
<div className="flex flex-1 overflow-auto">
|
||||
<div className="mt-2">
|
||||
<SidebarProvider
|
||||
|
|
|
|||
162
ui/litellm-dashboard/src/components/LicenseExpiryBanner.test.tsx
Normal file
162
ui/litellm-dashboard/src/components/LicenseExpiryBanner.test.tsx
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
import React from "react";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { LicenseExpiryBanner } from "./LicenseExpiryBanner";
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
|
||||
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(<LicenseExpiryBanner />);
|
||||
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(<LicenseExpiryBanner />);
|
||||
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(<LicenseExpiryBanner />);
|
||||
|
||||
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(<LicenseExpiryBanner />);
|
||||
|
||||
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(<LicenseExpiryBanner />);
|
||||
|
||||
expect(
|
||||
await screen.findByText("Enterprise License Expired")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should include renewal instructions in the description", async () => {
|
||||
mockGetLicenseInfo.mockResolvedValue(makeLicense("2026-03-20"));
|
||||
|
||||
render(<LicenseExpiryBanner />);
|
||||
|
||||
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(<LicenseExpiryBanner />);
|
||||
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(<LicenseExpiryBanner />);
|
||||
await waitFor(() => {
|
||||
expect(mockGetLicenseInfo).toHaveBeenCalled();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector(".ant-alert")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
64
ui/litellm-dashboard/src/components/LicenseExpiryBanner.tsx
Normal file
64
ui/litellm-dashboard/src/components/LicenseExpiryBanner.tsx
Normal file
|
|
@ -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<LicenseInfo | null>(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 (
|
||||
<Alert
|
||||
message={message}
|
||||
description={description}
|
||||
type={isExpired ? "error" : "warning"}
|
||||
showIcon
|
||||
banner
|
||||
closable={false}
|
||||
style={{ marginBottom: 0, borderRadius: 0 }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
Loading…
Add table
Reference in a new issue