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:
yuneng-jiang 2026-03-23 23:30:33 -07:00
parent 0ce1ea37c5
commit 6d21a31e66
3 changed files with 228 additions and 0 deletions

View file

@ -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

View 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();
});
});
});

View 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 }}
/>
);
};